Breadcrumb Schema Framework
Use conditional-logic schema generation to keep Breadcrumb markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralBreadcrumb documentationUse on pages that belong to a clear hierarchy and display a navigational trail users can follow. The trail should represent a typical user path to the page, not a mirror of the URL structure.
Key Implementation Documentation Highlights
- Required on BreadcrumbList: itemListElement, an ordered array of at least two ListItem entries.
- Required on each ListItem: name (the title shown to users), position (an integer, where 1 is the start of the trail), and item (the URL of the page the crumb represents).
- item is not required on the last ListItem in the trail. In JSON-LD it can be a URL string or an object with @id.
- Google lists no recommended properties for Breadcrumb.
- You do not need a ListItem for the top level (the domain or host name) or for the page itself.
- Pages with more than one trail can carry one BreadcrumbList per trail.
- Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability, then monitor the Breadcrumbs report in Search Console.
Rules the framework enforces before it emits a trail:
- Fewer than two ListItems: emit nothing.
- Positions are integers that start at 1 and increase by 1, with no gaps or duplicates. The framework assigns them from trail order instead of trusting stored values.
- Every ListItem except the last has an absolute item URL; a crumb in the middle without a valid URL suppresses the whole trail.
- Every ListItem has a non-empty name that matches the visible breadcrumb label.
Template Approach
const pageData = {
sectionName: "Guides",
sectionUrl: "https://www.example.com/guides/",
pageName: "Crawl Budget",
pageUrl: "https://www.example.com/guides/crawl-budget"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": pageData.sectionName, "item": pageData.sectionUrl },
{ "@type": "ListItem", "position": 2, "name": pageData.pageName, "item": pageData.pageUrl }
]
};
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Guides",
"item": "https://www.example.com/guides/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Crawl Budget",
"item": "https://www.example.com/guides/crawl-budget"
}
]
}
Conditional-logic Framework
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
function isAbsoluteUrl(value) {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return (url.protocol === "https:" || url.protocol === "http:") && Boolean(url.hostname);
} catch {
return false;
}
}
function isRealDate(y, m, d) {
const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d)));
return date.getUTCMonth() === Number(m) - 1 && date.getUTCDate() === Number(d);
}
// Calendar date (YYYY-MM-DD); a full date-time is cut to its date part.
function toIsoDate(value) {
if (typeof value !== "string") return null;
const v = value.trim();
const m = v.match(ISO_DATE) || v.match(ISO_DATE_TIME);
return m && isRealDate(m[1], m[2], m[3]) ? v.slice(0, 10) : null;
}
// Date-time with Z or an offset (kept as written), or a plain date.
// A date-time without a timezone is rejected instead of guessed.
function toIsoDateTime(value) {
if (typeof value !== "string") return null;
const v = value.trim();
const m = v.match(ISO_DATE) || v.match(ISO_DATE_TIME);
return m && isRealDate(m[1], m[2], m[3]) ? v : null;
}
// Drop null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((v) => v !== undefined);
return items.length ? items : undefined;
}
if (value && typeof value === "object") {
const out = {};
for (const [key, v] of Object.entries(value)) {
const c = compact(v);
if (c !== undefined) out[key] = c;
}
return Object.keys(out).length ? out : undefined;
}
if (value === null || value === undefined || value === "") return undefined;
return value;
}
// Page gates: indexable, self-canonical, and the marked-up content is visible.
function passesPageGates(source) {
if (!source.isIndexable || !source.isContentVisible) return false;
if (!isAbsoluteUrl(source.url)) return false;
return !source.canonicalUrl || source.canonicalUrl === source.url;
}
// trail: ordered crumbs as shown on the page, [{ name, url }].
// The last crumb (usually the current page) may omit url.
function buildBreadcrumbSchema(source) {
if (!passesPageGates(source)) return null;
const trail = (source.trail || []).filter((crumb) => crumb && crumb.name);
if (trail.length < 2) return null; // Google needs at least two ListItems
const lastIndex = trail.length - 1;
const itemListElement = [];
for (const [index, crumb] of trail.entries()) {
const hasUrl = isAbsoluteUrl(crumb.url);
// Every crumb except the last must carry an absolute item URL.
if (!hasUrl && index !== lastIndex) return null;
itemListElement.push({
"@type": "ListItem",
"position": index + 1, // 1..n, no gaps or duplicates
"name": String(crumb.name).trim(),
"item": hasUrl ? crumb.url : null
});
}
return compact({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": itemListElement
}) || null;
}
// Usage
// const source = {
// "isIndexable": true,
// "isContentVisible": true,
// "url": "https://www.example.com/guides/technical-seo/crawl-budget",
// "canonicalUrl": "https://www.example.com/guides/technical-seo/crawl-budget",
// "trail": [
// {
// "name": "Guides",
// "url": "https://www.example.com/guides/"
// },
// {
// "name": "Technical SEO",
// "url": "https://www.example.com/guides/technical-seo/"
// },
// {
// "name": "Crawl Budget",
// "url": "https://www.example.com/guides/technical-seo/crawl-budget"
// }
// ]
// };
// const jsonLd = buildBreadcrumbSchema(source); // null means: emit nothing
import re
from datetime import date
from urllib.parse import urlparse
ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
ISO_DATE_TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$")
def is_absolute_url(value):
if not isinstance(value, str):
return False
try:
parsed = urlparse(value)
except ValueError:
return False
return parsed.scheme in ("http", "https") and bool(parsed.hostname)
def is_real_date(y, m, d):
try:
date(int(y), int(m), int(d))
return True
except ValueError:
return False
# Calendar date (YYYY-MM-DD); a full date-time is cut to its date part.
def to_iso_date(value):
if not isinstance(value, str):
return None
v = value.strip()
m = ISO_DATE.match(v) or ISO_DATE_TIME.match(v)
return v[:10] if m and is_real_date(m[1], m[2], m[3]) else None
# Date-time with Z or an offset (kept as written), or a plain date.
# A date-time without a timezone is rejected instead of guessed.
def to_iso_date_time(value):
if not isinstance(value, str):
return None
v = value.strip()
m = ISO_DATE.match(v) or ISO_DATE_TIME.match(v)
return v if m and is_real_date(m[1], m[2], m[3]) else None
# Drop None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [c for c in (compact(v) for v in value) if c is not None]
return items or None
if isinstance(value, dict):
out = {}
for key, v in value.items():
c = compact(v)
if c is not None:
out[key] = c
return out or None
if value is None or value == "":
return None
return value
# Page gates: indexable, self-canonical, and the marked-up content is visible.
def passes_page_gates(source):
if not source.get("isIndexable") or not source.get("isContentVisible"):
return False
if not is_absolute_url(source.get("url")):
return False
return not source.get("canonicalUrl") or source.get("canonicalUrl") == source.get("url")
# trail: ordered crumbs as shown on the page, [{ name, url }].
# The last crumb (usually the current page) may omit url.
def build_breadcrumb_schema(source):
if not passes_page_gates(source):
return None
trail = [c for c in source.get("trail") or [] if c and c.get("name")]
if len(trail) < 2:
return None # Google needs at least two ListItems
last_index = len(trail) - 1
item_list_element = []
for index, crumb in enumerate(trail):
has_url = is_absolute_url(crumb.get("url"))
# Every crumb except the last must carry an absolute item URL.
if not has_url and index != last_index:
return None
item_list_element.append({
"@type": "ListItem",
"position": index + 1, # 1..n, no gaps or duplicates
"name": str(crumb["name"]).strip(),
"item": crumb["url"] if has_url else None,
})
return compact({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": item_list_element,
})
# Usage: json_ld = build_breadcrumb_schema(source) # same source dict as the JS example; None means emit nothing
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Guides",
"item": "https://www.example.com/guides/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Technical SEO",
"item": "https://www.example.com/guides/technical-seo/"
},
{
"@type": "ListItem",
"position": 3,
"name": "Crawl Budget",
"item": "https://www.example.com/guides/technical-seo/crawl-budget"
}
]
}
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 the trail from the same navigation data that renders the visible breadcrumb, so names and order cannot drift apart.
- Handles trails of any depth, including two-level pages, without a separate template per depth.
- Computes positions from order, so a CMS reorder never produces gaps, duplicates, or a trail that starts at 0.
- Suppresses the markup instead of emitting a broken trail when a mid-trail URL is missing or relative.
Property Reference
What Google's Breadcrumb documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
| Property | Expects | Notes | |
|---|---|---|---|
| itemListElement | Required | ListItem, can repeat | An ordered array of at least two ListItem breadcrumbs. |
| itemListElement.item | Required | URL or Thing | The URL to the webpage that represents the breadcrumb (not required for the last item in the trail). |
| itemListElement.name | Required | Text | The title of the breadcrumb displayed for the user. |
| itemListElement.position | Required | Integer | The position of the breadcrumb in the trail. Position 1 signifies the beginning of the trail. |