How-To Schema Framework
Use conditional-logic schema generation to keep How-To markup aligned with real page state and avoid stale or invalid static templates.
- Google no longer shows How-to rich results: they were limited to desktop on Aug 8, 2023 and removed from desktop on Sep 13, 2023, when Google deprecated the result type. Search appearance report and Rich Results Test support ended within 30 days.
- HowTo is still valid schema.org: it describes ordered steps, tools, supplies and duration for AI assistants and other structured data consumers.
- Keep it only where a page really is a step-by-step guide with visible steps, and expect no rich result. Do not add it to pages to chase a Search feature.
When To Use It
Google Search Central RetiredDocumentation changelogThe documentation page has been removed and its URL now redirects to the Search Central updates changelog. Use HowTo markup only on instructional pages whose steps are visible, for schema.org semantics rather than a Google feature.
Key Implementation Documentation Highlights
- Google lists no required properties: the How-to documentation is gone and the feature is retired.
- Google lists no recommended properties either. The framework below follows schema.org: name, step (HowToStep with text, plus optional name, url and image), totalTime, supply, tool, image and description.
- Write totalTime as an ISO 8601 duration such as
PT25MorPT1H30M. - General structured data policies still apply: mark up only content visible on the page and keep the page indexable.
- The Rich Results Test no longer reports How-to; use the Schema Markup Validator to check syntax.
Rules the framework enforces before it emits markup:
- Steps not visible on the page, or a noindex or canonicalized URL: emit nothing.
- No name, no steps, or a step without text: emit nothing rather than a partial guide.
- totalTime must be a valid ISO 8601 duration; whole minutes are converted, anything else is dropped.
- Step url values are #fragment links on the page itself, and image URLs must be absolute.
Template Approach
const pageData = {
title: "How to replace a bike chain",
totalTime: "PT25M",
steps: [
"Measure the old chain with a chain checker.",
"Open the quick link and remove the old chain.",
"Fit the new chain and close the quick link."
]
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "HowTo",
"name": pageData.title,
"totalTime": pageData.totalTime,
"step": pageData.steps.map((text) => ({ "@type": "HowToStep", "text": text }))
};
{
"@context": "https://schema.org",
"@type": "HowTo",
"name": "How to replace a bike chain",
"totalTime": "PT25M",
"step": [
{
"@type": "HowToStep",
"text": "Measure the old chain with a chain checker."
},
{
"@type": "HowToStep",
"text": "Open the quick link and remove the old chain."
},
{
"@type": "HowToStep",
"text": "Fit the new chain and close the quick link."
}
]
}
Conditional-logic Framework
function isAbsoluteUrl(value) {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return url.protocol === "https:" || url.protocol === "http:";
} catch {
return false;
}
}
const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
function toIsoDuration(value) {
// accepts an ISO 8601 duration string or a whole number of minutes
if (typeof value === "string") return ISO_DURATION.test(value.trim()) ? value.trim() : null;
if (!Number.isInteger(value) || value <= 0) return null;
const hours = Math.floor(value / 60);
const minutes = value % 60;
return "PT" + (hours ? hours + "H" : "") + (minutes ? minutes + "M" : "");
}
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((item) => item !== undefined);
return items.length ? items : undefined;
}
if (value && typeof value === "object") {
const out = {};
for (const [key, item] of Object.entries(value)) {
const kept = compact(item);
if (kept !== undefined) out[key] = kept;
}
return Object.keys(out).length ? out : undefined;
}
if (value === null || value === undefined || value === "") return undefined;
return value;
}
function buildStep(step, index, pageUrl) {
const text = (step.text || "").trim();
if (!text) return null;
return {
"@type": "HowToStep",
"position": index + 1,
"name": step.name,
"text": text,
"url": step.anchor ? pageUrl + "#" + step.anchor : null,
"image": isAbsoluteUrl(step.image) ? step.image : null
};
}
function buildHowToSchema(source) {
// Google retired How-to rich results in Sep 2023: no rich result, no Google required properties.
// Emit only when the steps are visible on the page, for schema.org semantics and other consumers.
if (source.indexable === false) return null;
if (!isAbsoluteUrl(source.url)) return null;
if (source.canonicalUrl && source.canonicalUrl !== source.url) return null;
if (source.stepsVisible !== true) return null;
const name = (source.title || "").trim();
const steps = (source.steps || []).map((s, i) => buildStep(s, i, source.url));
if (!name || !steps.length || steps.includes(null)) return null;
const schema = {
"@context": "https://schema.org",
"@type": "HowTo",
"name": name,
"description": source.description,
"image": isAbsoluteUrl(source.image) ? source.image : null,
"totalTime": toIsoDuration(source.totalMinutes),
"supply": (source.supplies || []).map((s) => ({ "@type": "HowToSupply", "name": s })),
"tool": (source.tools || []).map((t) => ({ "@type": "HowToTool", "name": t })),
"step": steps
};
return compact(schema);
}
// Usage:
// buildHowToSchema({
// url: "https://www.example.com/guides/replace-bike-chain",
// canonicalUrl: "https://www.example.com/guides/replace-bike-chain",
// indexable: true,
// stepsVisible: true,
// title: "How to replace a bike chain",
// description: "Swap a worn 11-speed chain with a chain tool and a quick link.",
// image: "https://www.example.com/guides/replace-bike-chain/hero.jpg",
// totalMinutes: 25,
// supplies: ["11-speed chain", "Quick link"],
// tools: ["Chain tool", "Chain checker"],
// steps: [
// { name: "Check wear", text: "Measure the old chain with a chain checker; replace it at 0.5% stretch.", anchor: "step-1" },
// { name: "Remove the old chain", text: "Open the quick link or push out a pin with the chain tool.", anchor: "step-2" },
// { name: "Size and fit the new chain", text: "Match the old chain length, thread it through the derailleur and close the quick link.", anchor: "step-3", image: "https://www.example.com/guides/replace-bike-chain/quick-link.jpg" }
// ]
// });
import re
from urllib.parse import urlparse
def is_absolute_url(value):
if not isinstance(value, str):
return False
parsed = urlparse(value)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
ISO_DURATION = re.compile(r"^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$")
def to_iso_duration(value):
# accepts an ISO 8601 duration string or a whole number of minutes
if isinstance(value, str):
return value.strip() if ISO_DURATION.match(value.strip()) else None
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
return None
hours, minutes = divmod(value, 60)
return "PT" + (f"{hours}H" if hours else "") + (f"{minutes}M" if minutes else "")
def compact(value):
if isinstance(value, list):
items = [kept for kept in (compact(item) for item in value) if kept is not None]
return items or None
if isinstance(value, dict):
out = {}
for key, item in value.items():
kept = compact(item)
if kept is not None:
out[key] = kept
return out or None
if value is None or value == "":
return None
return value
def build_step(step, index, page_url):
text = (step.get("text") or "").strip()
if not text:
return None
return {
"@type": "HowToStep",
"position": index + 1,
"name": step.get("name"),
"text": text,
"url": page_url + "#" + step["anchor"] if step.get("anchor") else None,
"image": step.get("image") if is_absolute_url(step.get("image")) else None,
}
def build_how_to_schema(source):
# Google retired How-to rich results in Sep 2023: no rich result, no Google required properties.
# Emit only when the steps are visible on the page, for schema.org semantics and other consumers.
if source.get("indexable") is False:
return None
if not is_absolute_url(source.get("url")):
return None
if source.get("canonicalUrl") and source["canonicalUrl"] != source["url"]:
return None
if source.get("stepsVisible") is not True:
return None
name = (source.get("title") or "").strip()
steps = [build_step(s, i, source["url"]) for i, s in enumerate(source.get("steps") or [])]
if not name or not steps or None in steps:
return None
schema = {
"@context": "https://schema.org",
"@type": "HowTo",
"name": name,
"description": source.get("description"),
"image": source.get("image") if is_absolute_url(source.get("image")) else None,
"totalTime": to_iso_duration(source.get("totalMinutes")),
"supply": [{"@type": "HowToSupply", "name": s} for s in source.get("supplies") or []],
"tool": [{"@type": "HowToTool", "name": t} for t in source.get("tools") or []],
"step": steps,
}
return compact(schema)
# Usage:
# build_how_to_schema({
# "url": "https://www.example.com/guides/replace-bike-chain",
# "canonicalUrl": "https://www.example.com/guides/replace-bike-chain",
# "indexable": True,
# "stepsVisible": True,
# "title": "How to replace a bike chain",
# "description": "Swap a worn 11-speed chain with a chain tool and a quick link.",
# "image": "https://www.example.com/guides/replace-bike-chain/hero.jpg",
# "totalMinutes": 25,
# "supplies": ["11-speed chain", "Quick link"],
# "tools": ["Chain tool", "Chain checker"],
# "steps": [
# {"name": "Check wear", "text": "Measure the old chain with a chain checker; replace it at 0.5% stretch.", "anchor": "step-1"},
# {"name": "Remove the old chain", "text": "Open the quick link or push out a pin with the chain tool.", "anchor": "step-2"},
# {"name": "Size and fit the new chain", "text": "Match the old chain length, thread it through the derailleur and close the quick link.", "anchor": "step-3", "image": "https://www.example.com/guides/replace-bike-chain/quick-link.jpg"},
# ],
# })
{
"@context": "https://schema.org",
"@type": "HowTo",
"name": "How to replace a bike chain",
"description": "Swap a worn 11-speed chain with a chain tool and a quick link.",
"image": "https://www.example.com/guides/replace-bike-chain/hero.jpg",
"totalTime": "PT25M",
"supply": [
{
"@type": "HowToSupply",
"name": "11-speed chain"
},
{
"@type": "HowToSupply",
"name": "Quick link"
}
],
"tool": [
{
"@type": "HowToTool",
"name": "Chain tool"
},
{
"@type": "HowToTool",
"name": "Chain checker"
}
],
"step": [
{
"@type": "HowToStep",
"position": 1,
"name": "Check wear",
"text": "Measure the old chain with a chain checker; replace it at 0.5% stretch.",
"url": "https://www.example.com/guides/replace-bike-chain#step-1"
},
{
"@type": "HowToStep",
"position": 2,
"name": "Remove the old chain",
"text": "Open the quick link or push out a pin with the chain tool.",
"url": "https://www.example.com/guides/replace-bike-chain#step-2"
},
{
"@type": "HowToStep",
"position": 3,
"name": "Size and fit the new chain",
"text": "Match the old chain length, thread it through the derailleur and close the quick link.",
"url": "https://www.example.com/guides/replace-bike-chain#step-3",
"image": "https://www.example.com/guides/replace-bike-chain/quick-link.jpg"
}
]
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Builds steps from the same ordered records that render the guide, so markup and visible steps stay in sync.
- Converts stored minutes to ISO 8601 durations instead of trusting free text such as 25 mins.
- Drops supplies, tools and images that the content model lacks, instead of emitting empty arrays.
- Keeps HowTo behind one builder, so removing it later is a single change.
Property Reference
Google retired the How-To rich result, so its documentation no longer lists required or recommended properties. The framework above is still valid schema.org markup and still describes the page to search engines and AI assistants.
Schema.org types used