Recipe Schema Framework
Use conditional-logic schema generation to keep Recipe markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralRecipe documentationUse for recipe pages with complete ingredients, instructions, and preparation details.
Key Implementation Documentation Highlights
- Required properties: name (the name of the dish) and image (the completed dish). Google lists no other required properties.
- Recommended properties: aggregateRating, author, cookTime, prepTime, totalTime, datePublished, description, keywords, nutrition.calories, recipeCategory, recipeCuisine, recipeIngredient, recipeInstructions, recipeYield, video.
- Times are ISO 8601 durations (for example PT1H30M). Always use cookTime and prepTime together, and provide totalTime or both of them.
- If nutrition.calories is present, recipeYield is required and must state the number of servings as a number (for example "4"); extra yields such as "24 cookies" can follow.
- recipeInstructions: prefer HowToStep (each step needs text or itemListElement) or HowToSection (name plus itemListElement of HowToSteps). Include only instruction text: no "Directions", "Step 1" or "Watch the video", and no step numbers in step names.
- Images must be crawlable, indexable and show the dish; for best results provide several high-resolution images (at least 50K pixels, width x height) in 16x9, 4x3 and 1x1. keywords are comma separated and must not repeat recipeCategory or recipeCuisine.
- Host carousels need ItemList markup on a summary page: each ListItem needs a position and a unique canonical url.
- Google expects content parity: every marked-up value must match what users can see on the page.
- Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability.
Template Approach
const pageData = {
title: "Lemon Garlic Sheet Pan Salmon",
image: "https://www.example.com/img/salmon-16x9.jpg",
description: "Salmon fillets roasted with lemon, garlic and green beans on one pan.",
authorName: "Maya Ortiz",
publishDate: "2026-03-18"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Recipe",
"name": pageData.title,
"image": [pageData.image],
"description": pageData.description,
"author": { "@type": "Person", "name": pageData.authorName },
"datePublished": pageData.publishDate,
"prepTime": "PT10M",
"cookTime": "PT15M",
"recipeYield": "4",
"recipeIngredient": ["4 salmon fillets", "1 lb green beans", "1 lemon, sliced"],
"recipeInstructions": [
{ "@type": "HowToStep", "text": "Toss the green beans with oil on a sheet pan." },
{ "@type": "HowToStep", "text": "Add the salmon, top with lemon and roast for 15 minutes." }
]
};
{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Lemon Garlic Sheet Pan Salmon",
"image": [
"https://www.example.com/img/salmon-16x9.jpg"
],
"description": "Salmon fillets roasted with lemon, garlic and green beans on one pan.",
"author": {
"@type": "Person",
"name": "Maya Ortiz"
},
"datePublished": "2026-03-18",
"prepTime": "PT10M",
"cookTime": "PT15M",
"recipeYield": "4",
"recipeIngredient": [
"4 salmon fillets",
"1 lb green beans",
"1 lemon, sliced"
],
"recipeInstructions": [
{
"@type": "HowToStep",
"text": "Toss the green beans with oil on a sheet pan."
},
{
"@type": "HowToStep",
"text": "Add the salmon, top with lemon and roast for 15 minutes."
}
]
}
Conditional-logic Framework
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$/;
function isAbsoluteUrl(value) {
return typeof value === "string" && /^https?:\/\/[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$/i.test(value);
}
// Returns the ISO 8601 string unchanged (timezone offset kept), or null if unparseable.
function toIsoDateTime(value) {
if (typeof value !== "string") return null;
const text = value.trim();
const m = text.match(ISO_DATE_TIME);
if (!m) return null;
const [, y, mo, d, h = "00", mi = "00", s = "00"] = m;
const date = new Date(Date.UTC(+y, +mo - 1, +d));
if (date.getUTCMonth() !== +mo - 1 || date.getUTCDate() !== +d) return null;
if (+h > 23 || +mi > 59 || +s > 59) return null;
return text;
}
function toIsoDate(value) {
const iso = toIsoDateTime(value);
return iso ? iso.slice(0, 10) : null;
}
// Drops 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;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
function passesPageGates(source) {
if (source.indexable === false) return false;
if (source.canonicalUrl && source.url && source.canonicalUrl !== source.url) return false;
return source.contentVisible !== false;
}
function toNumber(value) {
if (value === null || value === undefined || value === "" || typeof value === "boolean") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
const isCurrencyCode = (value) => typeof value === "string" && /^[A-Z]{3}$/.test(value);
// Rating or AggregateRating; null when ratingValue falls outside worstRating..bestRating.
function buildRating(input, type) {
if (!input) return null;
const best = toNumber(input.bestRating) ?? 5;
const worst = toNumber(input.worstRating) ?? 1;
const value = toNumber(input.ratingValue);
if (value === null || worst >= best || value < worst || value > best) return null;
const rating = { "@type": type, ratingValue: value, bestRating: best, worstRating: worst };
if (type === "AggregateRating") {
const ratingCount = toNumber(input.ratingCount);
const reviewCount = toNumber(input.reviewCount);
if (!(ratingCount > 0) && !(reviewCount > 0)) return null;
rating.ratingCount = ratingCount > 0 ? ratingCount : null;
rating.reviewCount = reviewCount > 0 ? reviewCount : null;
}
return rating;
}
// Review with a valid author name (under 100 characters) and an in-range rating.
function buildReview(input) {
const authorName = String(input?.authorName ?? "").trim();
const reviewRating = buildRating(input?.rating, "Rating");
if (!authorName || authorName.length >= 100 || !reviewRating) return null;
return {
"@type": "Review",
author: { "@type": input.authorType === "Organization" ? "Organization" : "Person", name: authorName },
reviewRating,
datePublished: toIsoDate(input.datePublished)
};
}
const ISO_DURATION = /^P(?=\d|T\d)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
// Minutes (number) become PT#H#M; strings must already be ISO 8601 durations.
function toIsoDuration(value) {
if (typeof value === "number" && Number.isInteger(value) && value > 0) {
const hours = Math.floor(value / 60);
const minutes = value % 60;
return `PT${hours ? `${hours}H` : ""}${minutes ? `${minutes}M` : ""}`;
}
return typeof value === "string" && ISO_DURATION.test(value.trim()) ? value.trim() : null;
}
const STEP_LABEL = /^(directions|instructions|watch the video|step\s*\d+)\s*[:.\-]?\s*/i;
const absoluteUrls = (list) => (list ?? []).filter(isAbsoluteUrl);
// A step video is a Clip of the recipe video: name, startOffset (seconds) and a url that jumps to it.
function buildClip(clip, name) {
const start = toNumber(clip?.startOffset);
const end = toNumber(clip?.endOffset);
if (!name || !Number.isInteger(start) || start < 0 || !isAbsoluteUrl(clip.url)) return null;
return { "@type": "Clip", name, startOffset: start, endOffset: end > start ? end : null, url: clip.url };
}
function buildStep(step) {
const text = String(step?.text ?? "").replace(STEP_LABEL, "").trim();
if (!text) return null;
const rawName = String(step.name ?? "").trim();
const name = rawName && !/^step\s*\d+$/i.test(rawName) ? rawName : null;
return {
"@type": "HowToStep",
name,
text,
url: isAbsoluteUrl(step.url) ? step.url : null,
image: isAbsoluteUrl(step.image) && /\.(jpe?g|png|gif)(\?.*)?$/i.test(step.image) ? step.image : null,
video: buildClip(step.clip, name)
};
}
// HowToSection needs a name and at least one valid HowToStep.
function buildInstruction(item) {
if (!Array.isArray(item?.steps)) return buildStep(item);
const steps = item.steps.map(buildStep).filter(Boolean);
const name = String(item.sectionName ?? "").trim();
return name && steps.length ? { "@type": "HowToSection", name, itemListElement: steps } : null;
}
function buildVideo(video) {
const uploadDate = toIsoDateTime(video?.uploadDate);
if (!video?.name || !isAbsoluteUrl(video.thumbnailUrl) || !uploadDate || !uploadDate.includes("T")) return null;
return {
"@type": "VideoObject",
name: video.name,
description: video.description,
thumbnailUrl: video.thumbnailUrl,
uploadDate,
duration: toIsoDuration(video.duration),
contentUrl: isAbsoluteUrl(video.contentUrl) ? video.contentUrl : null,
embedUrl: isAbsoluteUrl(video.embedUrl) ? video.embedUrl : null
};
}
function buildRecipeSchema(source) {
if (!passesPageGates(source)) return null;
// Required: name of the dish and at least one crawlable image of it.
const name = String(source.name ?? "").trim();
const images = absoluteUrls(source.images);
if (!name || images.length === 0) return null;
// prepTime and cookTime only as a pair; totalTime stands alone.
const prepTime = toIsoDuration(source.prepTime);
const cookTime = toIsoDuration(source.cookTime);
const pairOk = Boolean(prepTime && cookTime);
// Per-serving calories require recipeYield with the number of servings.
const servings = toNumber(source.servings);
const recipeYield = Number.isInteger(servings) && servings > 0
? [String(servings), ...(source.extraYields ?? [])]
: null;
const taxonomy = [source.category, source.cuisine].filter(Boolean).map((v) => v.toLowerCase());
const keywords = (source.keywords ?? []).filter((k) => !taxonomy.includes(k.toLowerCase()));
return compact({
"@context": "https://schema.org",
"@type": "Recipe",
name,
image: images,
description: source.description,
author: (source.authors ?? []).map((a) => ({ "@type": a.type === "Organization" ? "Organization" : "Person", name: a.name, url: isAbsoluteUrl(a.url) ? a.url : null })),
datePublished: toIsoDate(source.datePublished),
prepTime: pairOk ? prepTime : null,
cookTime: pairOk ? cookTime : null,
totalTime: toIsoDuration(source.totalTime),
recipeYield,
recipeCategory: source.category,
recipeCuisine: source.cuisine,
keywords: keywords.join(", "),
nutrition: recipeYield && source.caloriesPerServing
? { "@type": "NutritionInformation", calories: `${source.caloriesPerServing} calories` }
: null,
recipeIngredient: (source.ingredients ?? []).map((i) => String(i).trim()),
recipeInstructions: (source.instructions ?? []).map(buildInstruction).filter(Boolean),
aggregateRating: buildRating(source.rating, "AggregateRating"),
video: buildVideo(source.video)
});
}
// Usage:
// const source = {
// url: "https://www.example.com/recipes/lemon-garlic-salmon",
// canonicalUrl: "https://www.example.com/recipes/lemon-garlic-salmon",
// indexable: true,
// contentVisible: true,
// name: "Lemon Garlic Sheet Pan Salmon",
// images: [
// "https://www.example.com/img/salmon-16x9.jpg",
// "https://www.example.com/img/salmon-4x3.jpg",
// "https://www.example.com/img/salmon-1x1.jpg"
// ],
// description: "Salmon fillets roasted with lemon, garlic and green beans on one pan.",
// authors: [{ name: "Maya Ortiz", url: "https://www.example.com/authors/maya-ortiz" }],
// datePublished: "2026-03-18",
// prepTime: 10,
// cookTime: 15,
// totalTime: 25,
// servings: 4,
// category: "Dinner",
// cuisine: "American",
// keywords: ["weeknight", "sheet pan", "dinner"],
// caloriesPerServing: 410,
// ingredients: ["4 salmon fillets", "1 lb green beans", "3 cloves garlic, minced", "1 lemon, sliced", "2 tbsp olive oil"],
// instructions: [
// {
// name: "Prep the pan",
// text: "Step 1: Heat the oven to 425F and toss the green beans with oil on a sheet pan.",
// url: "https://www.example.com/recipes/lemon-garlic-salmon#step-1",
// image: "https://www.example.com/img/salmon-step-1.jpg",
// clip: { startOffset: 0, endOffset: 45, url: "https://www.example.com/video/salmon?t=0" }
// },
// {
// name: "Add the salmon",
// text: "Nestle the salmon among the beans, top with garlic and lemon slices.",
// url: "https://www.example.com/recipes/lemon-garlic-salmon#step-2",
// image: "https://www.example.com/img/salmon-step-2.jpg",
// clip: { startOffset: 45, endOffset: 100, url: "https://www.example.com/video/salmon?t=45" }
// },
// {
// name: "Roast",
// text: "Roast for 12 to 15 minutes, until the salmon flakes easily.",
// url: "https://www.example.com/recipes/lemon-garlic-salmon#step-3",
// image: "https://www.example.com/img/salmon-step-3.jpg",
// clip: { startOffset: 100, endOffset: 160, url: "https://www.example.com/video/salmon?t=100" }
// }
// ],
// rating: { ratingValue: 4.8, ratingCount: 312 },
// video: {
// name: "How to make lemon garlic sheet pan salmon",
// description: "Watch the salmon and green beans go from pan to plate in 25 minutes.",
// thumbnailUrl: "https://www.example.com/img/salmon-video-thumb.jpg",
// uploadDate: "2026-03-18T09:00:00-04:00",
// duration: "PT2M40S",
// contentUrl: "https://www.example.com/video/salmon.mp4",
// embedUrl: "https://www.example.com/embed/salmon"
// }
// };
// const jsonLd = buildRecipeSchema(source);
import re
from datetime import date, datetime
ISO_DATE_TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$")
ABSOLUTE_URL = re.compile(r"^https?://[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$", re.I)
def is_absolute_url(value):
return isinstance(value, str) and bool(ABSOLUTE_URL.match(value))
# Returns the ISO 8601 string unchanged (timezone offset kept), or None if unparseable.
def to_iso_date_time(value):
if not isinstance(value, str):
return None
text = value.strip()
m = ISO_DATE_TIME.match(text)
if not m:
return None
y, mo, d = int(m[1]), int(m[2]), int(m[3])
try:
date(y, mo, d)
except ValueError:
return None
if int(m[4] or 0) > 23 or int(m[5] or 0) > 59 or int(m[6] or 0) > 59:
return None
return text
def to_iso_date(value):
iso = to_iso_date_time(value)
return iso[:10] if iso else None
# Drops 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 = {k: c for k, c in ((k, compact(v)) for k, v in value.items()) if c is not None}
return out or None
return None if value is None or value == "" else value
def passes_page_gates(source):
if source.get("indexable") is False:
return False
if source.get("canonicalUrl") and source.get("url") and source["canonicalUrl"] != source["url"]:
return False
return source.get("contentVisible") is not False
def to_number(value):
if value is None or value == "" or isinstance(value, bool):
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num != num or num in (float("inf"), float("-inf")):
return None
return int(num) if num.is_integer() else num
def is_currency_code(value):
return isinstance(value, str) and re.fullmatch(r"[A-Z]{3}", value) is not None
# Rating or AggregateRating; None when ratingValue falls outside worstRating..bestRating.
def build_rating(data, rating_type):
if not data:
return None
best = to_number(data.get("bestRating"))
best = 5 if best is None else best
worst = to_number(data.get("worstRating"))
worst = 1 if worst is None else worst
value = to_number(data.get("ratingValue"))
if value is None or worst >= best or value < worst or value > best:
return None
rating = {"@type": rating_type, "ratingValue": value, "bestRating": best, "worstRating": worst}
if rating_type == "AggregateRating":
rating_count = to_number(data.get("ratingCount")) or 0
review_count = to_number(data.get("reviewCount")) or 0
if rating_count <= 0 and review_count <= 0:
return None
rating["ratingCount"] = rating_count if rating_count > 0 else None
rating["reviewCount"] = review_count if review_count > 0 else None
return rating
# Review with a valid author name (under 100 characters) and an in-range rating.
def build_review(data):
data = data or {}
author_name = str(data.get("authorName") or "").strip()
review_rating = build_rating(data.get("rating"), "Rating")
if not author_name or len(author_name) >= 100 or not review_rating:
return None
return {
"@type": "Review",
"author": {"@type": "Organization" if data.get("authorType") == "Organization" else "Person", "name": author_name},
"reviewRating": review_rating,
"datePublished": to_iso_date(data.get("datePublished")),
}
ISO_DURATION = re.compile(r"^P(?=\d|T\d)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$")
# Minutes (int) become PT#H#M; strings must already be ISO 8601 durations.
def to_iso_duration(value):
if isinstance(value, int) and not isinstance(value, bool) and value > 0:
hours, minutes = divmod(value, 60)
return "PT" + (f"{hours}H" if hours else "") + (f"{minutes}M" if minutes else "")
if isinstance(value, str) and ISO_DURATION.match(value.strip()):
return value.strip()
return None
STEP_LABEL = re.compile(r"^(directions|instructions|watch the video|step\s*\d+)\s*[:.\-]?\s*", re.I)
def absolute_urls(items):
return [u for u in items or [] if is_absolute_url(u)]
# A step video is a Clip of the recipe video: name, startOffset (seconds) and a url that jumps to it.
def build_clip(clip, name):
clip = clip or {}
start = to_number(clip.get("startOffset"))
end = to_number(clip.get("endOffset"))
if not name or not isinstance(start, int) or start < 0 or not is_absolute_url(clip.get("url")):
return None
return {"@type": "Clip", "name": name, "startOffset": start,
"endOffset": end if end is not None and end > start else None, "url": clip["url"]}
def build_step(step):
step = step or {}
text = STEP_LABEL.sub("", str(step.get("text") or ""), count=1).strip()
if not text:
return None
raw_name = str(step.get("name") or "").strip()
name = raw_name if raw_name and not re.fullmatch(r"step\s*\d+", raw_name, re.I) else None
image = step.get("image")
return {
"@type": "HowToStep",
"name": name,
"text": text,
"url": step.get("url") if is_absolute_url(step.get("url")) else None,
"image": image if is_absolute_url(image) and re.search(r"\.(jpe?g|png|gif)(\?.*)?$", image, re.I) else None,
"video": build_clip(step.get("clip"), name),
}
# HowToSection needs a name and at least one valid HowToStep.
def build_instruction(item):
if not isinstance((item or {}).get("steps"), list):
return build_step(item)
steps = [s for s in (build_step(x) for x in item["steps"]) if s]
name = str(item.get("sectionName") or "").strip()
return {"@type": "HowToSection", "name": name, "itemListElement": steps} if name and steps else None
def build_video(video):
video = video or {}
upload_date = to_iso_date_time(video.get("uploadDate"))
if not video.get("name") or not is_absolute_url(video.get("thumbnailUrl")) or not upload_date or "T" not in upload_date:
return None
return {
"@type": "VideoObject",
"name": video["name"],
"description": video.get("description"),
"thumbnailUrl": video["thumbnailUrl"],
"uploadDate": upload_date,
"duration": to_iso_duration(video.get("duration")),
"contentUrl": video.get("contentUrl") if is_absolute_url(video.get("contentUrl")) else None,
"embedUrl": video.get("embedUrl") if is_absolute_url(video.get("embedUrl")) else None,
}
def build_recipe_schema(source):
if not passes_page_gates(source):
return None
# Required: name of the dish and at least one crawlable image of it.
name = str(source.get("name") or "").strip()
images = absolute_urls(source.get("images"))
if not name or not images:
return None
# prepTime and cookTime only as a pair; totalTime stands alone.
prep_time = to_iso_duration(source.get("prepTime"))
cook_time = to_iso_duration(source.get("cookTime"))
pair_ok = bool(prep_time and cook_time)
# Per-serving calories require recipeYield with the number of servings.
servings = to_number(source.get("servings"))
recipe_yield = None
if isinstance(servings, int) and servings > 0:
recipe_yield = [str(servings), *(source.get("extraYields") or [])]
taxonomy = [v.lower() for v in (source.get("category"), source.get("cuisine")) if v]
keywords = [k for k in source.get("keywords") or [] if k.lower() not in taxonomy]
return compact({
"@context": "https://schema.org",
"@type": "Recipe",
"name": name,
"image": images,
"description": source.get("description"),
"author": [
{"@type": "Organization" if a.get("type") == "Organization" else "Person", "name": a.get("name"),
"url": a.get("url") if is_absolute_url(a.get("url")) else None}
for a in source.get("authors") or []
],
"datePublished": to_iso_date(source.get("datePublished")),
"prepTime": prep_time if pair_ok else None,
"cookTime": cook_time if pair_ok else None,
"totalTime": to_iso_duration(source.get("totalTime")),
"recipeYield": recipe_yield,
"recipeCategory": source.get("category"),
"recipeCuisine": source.get("cuisine"),
"keywords": ", ".join(keywords),
"nutrition": {"@type": "NutritionInformation", "calories": f"{source['caloriesPerServing']} calories"}
if recipe_yield and source.get("caloriesPerServing") else None,
"recipeIngredient": [str(i).strip() for i in source.get("ingredients") or []],
"recipeInstructions": [i for i in (build_instruction(x) for x in source.get("instructions") or []) if i],
"aggregateRating": build_rating(source.get("rating"), "AggregateRating"),
"video": build_video(source.get("video")),
})
{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Lemon Garlic Sheet Pan Salmon",
"image": [
"https://www.example.com/img/salmon-16x9.jpg",
"https://www.example.com/img/salmon-4x3.jpg",
"https://www.example.com/img/salmon-1x1.jpg"
],
"description": "Salmon fillets roasted with lemon, garlic and green beans on one pan.",
"author": [
{
"@type": "Person",
"name": "Maya Ortiz",
"url": "https://www.example.com/authors/maya-ortiz"
}
],
"datePublished": "2026-03-18",
"prepTime": "PT10M",
"cookTime": "PT15M",
"totalTime": "PT25M",
"recipeYield": [
"4"
],
"recipeCategory": "Dinner",
"recipeCuisine": "American",
"keywords": "weeknight, sheet pan",
"nutrition": {
"@type": "NutritionInformation",
"calories": "410 calories"
},
"recipeIngredient": [
"4 salmon fillets",
"1 lb green beans",
"3 cloves garlic, minced",
"1 lemon, sliced",
"2 tbsp olive oil"
],
"recipeInstructions": [
{
"@type": "HowToStep",
"name": "Prep the pan",
"text": "Heat the oven to 425F and toss the green beans with oil on a sheet pan.",
"url": "https://www.example.com/recipes/lemon-garlic-salmon#step-1",
"image": "https://www.example.com/img/salmon-step-1.jpg",
"video": {
"@type": "Clip",
"name": "Prep the pan",
"startOffset": 0,
"endOffset": 45,
"url": "https://www.example.com/video/salmon?t=0"
}
},
{
"@type": "HowToStep",
"name": "Add the salmon",
"text": "Nestle the salmon among the beans, top with garlic and lemon slices.",
"url": "https://www.example.com/recipes/lemon-garlic-salmon#step-2",
"image": "https://www.example.com/img/salmon-step-2.jpg",
"video": {
"@type": "Clip",
"name": "Add the salmon",
"startOffset": 45,
"endOffset": 100,
"url": "https://www.example.com/video/salmon?t=45"
}
},
{
"@type": "HowToStep",
"name": "Roast",
"text": "Roast for 12 to 15 minutes, until the salmon flakes easily.",
"url": "https://www.example.com/recipes/lemon-garlic-salmon#step-3",
"image": "https://www.example.com/img/salmon-step-3.jpg",
"video": {
"@type": "Clip",
"name": "Roast",
"startOffset": 100,
"endOffset": 160,
"url": "https://www.example.com/video/salmon?t=100"
}
}
],
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.8,
"bestRating": 5,
"worstRating": 1,
"ratingCount": 312
},
"video": {
"@type": "VideoObject",
"name": "How to make lemon garlic sheet pan salmon",
"description": "Watch the salmon and green beans go from pan to plate in 25 minutes.",
"thumbnailUrl": "https://www.example.com/img/salmon-video-thumb.jpg",
"uploadDate": "2026-03-18T09:00:00-04:00",
"duration": "PT2M40S",
"contentUrl": "https://www.example.com/video/salmon.mp4",
"embedUrl": "https://www.example.com/embed/salmon"
}
}
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
- Prevents emitting empty, null, or stale fields that frequently trigger rich result warnings.
- Supports multiple page states (for example: no rating yet, no image yet, no offers yet) without duplicate templates.
- Lets engineering teams centralize schema policy checks and validation in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's Recipe 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 recipeInstructions: howToStep requires text or itemListElement.
| Property | Expects | Notes | |
|---|---|---|---|
| image | Required | URL or ImageObject, can repeat | Provide an image of the completed dish. Aspect ratios: 16x9, 4x3, 1x1. |
| name | Required | Text | The name of the dish. |
| aggregateRating | Recommended | AggregateRating | The average review score for the recipe. |
| author | Recommended | Person or Organization, can repeat | The person or organization that wrote the recipe. |
| cookTime | Recommended | Duration | ISO 8601 duration. , always together with prepTime. |
| datePublished | Recommended | Date | In ISO 8601 format. |
| description | Recommended | Text | A short summary describing the dish. |
| keywords | Recommended | Text | Comma separated. , not duplicating recipeCategory or recipeCuisine. |
| nutrition | Recommended | NutritionInformation | Google documents nutrition.calories. If nutrition.calories is defined, recipeYield must be defined with the number of servings. |
| nutrition.calories | Recommended | Energy or Text | Calories per serving. |
| prepTime | Recommended | Duration | ISO 8601 duration. , always together with cookTime. |
| recipeCategory | Recommended | Text | The type of meal or course. |
| recipeCuisine | Recommended | Text | The region associated with the recipe. |
| recipeIngredient | Recommended | Text, can repeat | One entry per ingredient. |
| recipeInstructions | Recommended | HowToStep, HowToSection or Text, can repeat | Preferably as HowToStep items. |
| recipeInstructions.itemListElement | One of | HowToDirection or HowToTip, can repeat | HowToStep needs text or itemListElement. |
| recipeInstructions.text | One of | Text | HowToStep needs text or itemListElement. |
| recipeInstructions.image | Recommended | URL or ImageObject | Image for this step. |
| recipeInstructions.name | Recommended | Text | Short descriptive step name (no step numbers) |
| recipeInstructions.url | Recommended | URL | Linking directly to the step. |
| recipeInstructions.video | Recommended | VideoObject or Clip | Video or Clip for this step. |
| recipeYield | Recommended | Text or Integer, can repeat | The number of servings as a number. |
| totalTime | Recommended | Duration | Or both cookTime and prepTime (ISO 8601 durations) |
| video | Recommended | VideoObject | A VideoObject depicting the steps to make the dish. |