Carousel Schema Framework
Use conditional-logic schema generation to keep Carousel markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralCarousel documentationUse when a page lists several items of one supported type (courses, movies, recipes, or restaurants) that Google can show as a host carousel, a list-like result people can swipe through on mobile devices. ItemList markup alone is not enough: it is only eligible when combined with Course list, Movie, Recipe, or Restaurant markup.
Key Implementation Documentation Highlights
- Required on ItemList: itemListElement, with at least two ListItem entries. Google lists no recommended properties for the list itself.
- Required on each ListItem: position, a 1-based integer. Items are shown in position order.
- Each ListItem needs either url or item, depending on the page pattern. Summary page: url is the canonical URL of the item detail page; all URLs must be unique and live on the same domain. All-in-one page: item holds the full object with item.name, item.url (the current page URL plus an anchor to the visible item), and the properties of its own type.
- Supported item types: Course (course list), Movie, Recipe, and Restaurant. All items in one list must be the same type, and each must meet that type's own required properties.
- Availability depends on the type: the course list is available in English in all regions where Google Search is available; the movie carousel is only available on mobile devices; the restaurant carousel is limited to a small set of providers who register interest through a Google form.
- The carousel markup must contain all the items listed on the page, and the visible text must be similar to the structured data.
- Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability.
Rules the framework enforces before it emits a list:
- Never mix patterns: either every ListItem has url (summary) or every ListItem has item (all-in-one).
- Positions are assigned 1..n from display order, so they are unique and increase by 1.
- Summary pattern: detail URLs are absolute, unique, and share one domain.
- All-in-one pattern: item @type is Course, Movie, Recipe, or Restaurant for every item, each anchor is unique, and each item carries its type's required properties (name and image for Movie, Recipe, and Restaurant; name and description for Course).
Template Approach
const pageData = {
recipeUrls: [
"https://www.example.com/recipes/cacio-e-pepe",
"https://www.example.com/recipes/pasta-al-limone",
"https://www.example.com/recipes/pasta-e-ceci"
]
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "url": pageData.recipeUrls[0] },
{ "@type": "ListItem", "position": 2, "url": pageData.recipeUrls[1] },
{ "@type": "ListItem", "position": 3, "url": pageData.recipeUrls[2] }
]
};
{
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"url": "https://www.example.com/recipes/cacio-e-pepe"
},
{
"@type": "ListItem",
"position": 2,
"url": "https://www.example.com/recipes/pasta-al-limone"
},
{
"@type": "ListItem",
"position": 3,
"url": "https://www.example.com/recipes/pasta-e-ceci"
}
]
}
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;
}
// Host carousels only support these item types, each with its own required fields.
const REQUIRED_BY_TYPE = {
Course: ["name", "description"],
Movie: ["name", "image"],
Recipe: ["name", "image"],
Restaurant: ["name", "image"]
};
function hostOf(url) {
return isAbsoluteUrl(url) ? new URL(url).hostname : null;
}
// Summary page: every item links to its own detail page, all on one domain.
// All-in-one page: every item is described on this page under its own anchor.
function chooseCarouselPattern(source) {
const items = source.items || [];
const hosts = new Set(items.map((i) => hostOf(i.detailUrl)));
if (items.length && hosts.size === 1 && !hosts.has(null)) return "summary";
if (items.length && items.every((i) => i.anchor && i.properties)) return "allInOne";
return null;
}
function buildSummaryElements(items) {
const urls = items.map((i) => i.detailUrl);
if (new Set(urls).size !== urls.length) return null; // every url must be unique
return items.map((i, index) => ({ "@type": "ListItem", "position": index + 1, "url": i.detailUrl }));
}
function buildAllInOneElements(source) {
const itemType = source.itemType;
const required = REQUIRED_BY_TYPE[itemType];
if (!required) return null; // Course, Movie, Recipe or Restaurant only
const anchors = source.items.map((i) => i.anchor);
if (new Set(anchors).size !== anchors.length) return null; // one unique anchor per item
const elements = [];
for (const [index, i] of source.items.entries()) {
// item.url is this page plus the anchor of the visible item.
const item = compact({ "@type": itemType, ...i.properties, "url": `${source.url}#${i.anchor}` });
if (!item || required.some((key) => item[key] === undefined)) return null;
if (item.image !== undefined && ![].concat(item.image).every((u) => isAbsoluteUrl(typeof u === "string" ? u : u.url))) return null;
elements.push({ "@type": "ListItem", "position": index + 1, "item": item });
}
return elements;
}
function buildCarouselSchema(source) {
if (!passesPageGates(source)) return null;
const items = source.items || [];
if (items.length < 2) return null; // an ItemList needs at least two ListItems
const pattern = chooseCarouselPattern(source);
const itemListElement =
pattern === "summary" ? buildSummaryElements(items) :
pattern === "allInOne" ? buildAllInOneElements(source) :
null; // never mix patterns in one list
if (!itemListElement) return null;
return compact({
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": itemListElement
}) || null;
}
// Usage
// const source = {
// "isIndexable": true,
// "isContentVisible": true,
// "url": "https://www.example.com/collections/weeknight-pasta",
// "canonicalUrl": "https://www.example.com/collections/weeknight-pasta",
// "itemType": "Recipe",
// "items": [
// {
// "anchor": "cacio-e-pepe",
// "properties": {
// "name": "Cacio e Pepe",
// "image": [
// "https://www.example.com/img/cacio-e-pepe-16x9.jpg"
// ],
// "author": {
// "@type": "Person",
// "name": "Lena Russo"
// },
// "datePublished": "2026-03-02",
// "totalTime": "PT25M"
// }
// },
// {
// "anchor": "pasta-al-limone",
// "properties": {
// "name": "Pasta al Limone",
// "image": [
// "https://www.example.com/img/pasta-al-limone-16x9.jpg"
// ],
// "author": {
// "@type": "Person",
// "name": "Lena Russo"
// },
// "datePublished": "2026-04-18",
// "totalTime": "PT20M"
// }
// },
// {
// "anchor": "pasta-e-ceci",
// "properties": {
// "name": "Pasta e Ceci",
// "image": [
// "https://www.example.com/img/pasta-e-ceci-16x9.jpg"
// ],
// "author": {
// "@type": "Person",
// "name": "Marco Bianchi"
// },
// "datePublished": "2026-05-09",
// "totalTime": "PT35M"
// }
// }
// ]
// };
// const jsonLd = buildCarouselSchema(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")
# Host carousels only support these item types, each with its own required fields.
REQUIRED_BY_TYPE = {
"Course": ["name", "description"],
"Movie": ["name", "image"],
"Recipe": ["name", "image"],
"Restaurant": ["name", "image"],
}
def host_of(url):
return urlparse(url).hostname if is_absolute_url(url) else None
# Summary page: every item links to its own detail page, all on one domain.
# All-in-one page: every item is described on this page under its own anchor.
def choose_carousel_pattern(source):
items = source.get("items") or []
hosts = {host_of(i.get("detailUrl")) for i in items}
if items and len(hosts) == 1 and None not in hosts:
return "summary"
if items and all(i.get("anchor") and i.get("properties") for i in items):
return "allInOne"
return None
def build_summary_elements(items):
urls = [i["detailUrl"] for i in items]
if len(set(urls)) != len(urls):
return None # every url must be unique
return [{"@type": "ListItem", "position": index + 1, "url": i["detailUrl"]} for index, i in enumerate(items)]
def build_all_in_one_elements(source):
item_type = source.get("itemType")
required = REQUIRED_BY_TYPE.get(item_type)
if not required:
return None # Course, Movie, Recipe or Restaurant only
anchors = [i["anchor"] for i in source["items"]]
if len(set(anchors)) != len(anchors):
return None # one unique anchor per item
elements = []
for index, i in enumerate(source["items"]):
# item.url is this page plus the anchor of the visible item.
item = compact({"@type": item_type, **i["properties"], "url": f"{source['url']}#{i['anchor']}"})
if not item or any(key not in item for key in required):
return None
images = item.get("image")
if images is not None:
images = images if isinstance(images, list) else [images]
if not all(is_absolute_url(u if isinstance(u, str) else u.get("url")) for u in images):
return None
elements.append({"@type": "ListItem", "position": index + 1, "item": item})
return elements
def build_carousel_schema(source):
if not passes_page_gates(source):
return None
items = source.get("items") or []
if len(items) < 2:
return None # an ItemList needs at least two ListItems
pattern = choose_carousel_pattern(source)
if pattern == "summary":
item_list_element = build_summary_elements(items)
elif pattern == "allInOne":
item_list_element = build_all_in_one_elements(source)
else:
item_list_element = None # never mix patterns in one list
if not item_list_element:
return None
return compact({
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": item_list_element,
})
# Usage: json_ld = build_carousel_schema(source) # same source dict as the JS example; None means emit nothing
{
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"item": {
"@type": "Recipe",
"name": "Cacio e Pepe",
"image": [
"https://www.example.com/img/cacio-e-pepe-16x9.jpg"
],
"author": {
"@type": "Person",
"name": "Lena Russo"
},
"datePublished": "2026-03-02",
"totalTime": "PT25M",
"url": "https://www.example.com/collections/weeknight-pasta#cacio-e-pepe"
}
},
{
"@type": "ListItem",
"position": 2,
"item": {
"@type": "Recipe",
"name": "Pasta al Limone",
"image": [
"https://www.example.com/img/pasta-al-limone-16x9.jpg"
],
"author": {
"@type": "Person",
"name": "Lena Russo"
},
"datePublished": "2026-04-18",
"totalTime": "PT20M",
"url": "https://www.example.com/collections/weeknight-pasta#pasta-al-limone"
}
},
{
"@type": "ListItem",
"position": 3,
"item": {
"@type": "Recipe",
"name": "Pasta e Ceci",
"image": [
"https://www.example.com/img/pasta-e-ceci-16x9.jpg"
],
"author": {
"@type": "Person",
"name": "Marco Bianchi"
},
"datePublished": "2026-05-09",
"totalTime": "PT35M",
"url": "https://www.example.com/collections/weeknight-pasta#pasta-e-ceci"
}
}
]
}
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
- Picks the summary or all-in-one pattern from the data and never mixes the two in one list.
- Keeps the list complete and in display order as items are added, removed, or reordered, with positions recomputed every time.
- Checks each item against its own type's required properties instead of emitting a list Google cannot use.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's Carousel documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Required, one of: In itemListElement: each ListItem needs url (summary page) or item (all-in-one page).
| Property | Expects | Notes | |
|---|---|---|---|
| itemListElement | Required | ListItem, can repeat | An ItemList must contain at least two ListItem elements. |
| itemListElement.position | Required | Integer | The item's position in the carousel (1-based). |
| itemListElement.url | Required | URL | The canonical URL of the item detail page. All URLs must be unique and on the same domain. |
| itemListElement.item | Required | Course, Movie, Recipe, Restaurant or Thing | The full object for this list item (all-in-one page pattern). |