Recipe
On this page. Name, images, timings, yield, ingredients, steps, nutrition and rating.
One recipe with timed steps, nutrition, ratings and a video whose key moments line up with the written steps, by the same author as the journal articles.
Use on a page whose main content is one recipe, with the ingredients and steps visible on the page.
The recipe video gets key moments built from the same steps, so a searcher can jump to "Add spices and simmer" in the video. The author is the same Person @id as on the journal articles, and the publisher is the Organization from the homepage.
Each card opens the framework for that feature, with its own rules and property reference. Cards marked "Linked by @id" are defined on the homepage and only referenced here.
On this page. Name, images, timings, yield, ingredients, steps, nutrition and rating.
On this page. The recipe video, nested in the Recipe.
On this page. A Clip per timed step, so the video moments match the written steps.
On this page. Readers' aggregate rating, shown as stars in the recipe result.
On this page. Home, Journal, recipe.
Linked by @id. The author Person, defined on their profile page.
Linked by @id. Publisher, by @id from the homepage.
Every node has a stable @id. A node is written out once, where it belongs, and everywhere else it is referenced by that @id alone. This is the map for this page, generated from the output below.
| @id | Defined on | Type | Referenced by |
|---|---|---|---|
/journal/one-pot-camp-chili/#webpage | This page | WebPage | Recipe.mainEntityOfPage |
/journal/one-pot-camp-chili/#breadcrumb | This page | BreadcrumbList | WebPage.breadcrumb |
/journal/one-pot-camp-chili/#recipe | This page | Recipe | WebPage.mainEntity |
/#organization | Homepage | OnlineStore | Recipe.publisher |
/#website | Homepage | WebSite | WebPage.isPartOf |
/journal/authors/maya-okafor/#person | Profile page | Person | Recipe.author |
One function turns the page's data into the whole graph. It drops anything Google would reject instead of shipping it half built. The input below includes rows it is meant to drop, so you can see the logic work.
// Recipe page schema: one recipe from the store's journal.
// Recipe with ingredients, timed steps, nutrition and a video whose key
// moments line up with the steps, the author as the same Person @id used on
// articles, the page's WebPage and BreadcrumbList, and the Organization from
// the homepage as publisher.
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(Z|[+-]\d{2}:\d{2})$/;
const clean = (v) => (typeof v === 'string' ? v.trim() : v);
const isUrl = (v) => typeof v === 'string' && /^https:\/\/[^\s]+$/.test(v);
const compact = (obj) => {
if (Array.isArray(obj)) {
const out = obj.map(compact).filter((v) => v !== undefined);
return out.length ? out : undefined;
}
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const c = compact(v);
if (c !== undefined) out[k] = c;
}
return Object.keys(out).length ? out : undefined;
}
return obj === null || obj === '' ? undefined : obj;
};
// Minutes to an ISO 8601 duration: 75 becomes PT1H15M.
const duration = (min) => {
if (!Number.isInteger(min) || min <= 0) return undefined;
const h = Math.floor(min / 60);
const m = min % 60;
return `PT${h ? `${h}H` : ''}${m ? `${m}M` : ''}`;
};
function breadcrumb(trail, id) {
const items = (trail || []).filter((c) => c.name && isUrl(c.url));
if (items.length < 2) return undefined;
return {
'@type': 'BreadcrumbList',
'@id': id,
itemListElement: items.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: clean(c.name), item: c.url })),
};
}
// Steps become HowToStep entries with their own anchor on the page. A step
// with a video timestamp also becomes a Clip, so the video's key moments
// match the written steps.
function steps(list, pageUrl) {
return (list || [])
.filter((s) => s.text && s.text.trim().length > 10)
.map((s, i) => ({
'@type': 'HowToStep',
name: clean(s.name) || `Step ${i + 1}`,
text: clean(s.text),
url: `${pageUrl}#step-${i + 1}`,
image: isUrl(s.image) ? s.image : undefined,
clip: Number.isInteger(s.videoStart) ? { start: s.videoStart, name: clean(s.name) || `Step ${i + 1}`, url: `${pageUrl}#step-${i + 1}` } : undefined,
}));
}
function video(v, howToSteps, pageUrl) {
if (!v || !v.name || !isUrl(v.thumbnailUrl) || !ISO_DATE_TIME.test(v.uploadDate || '')) return undefined;
if (!isUrl(v.contentUrl) && !isUrl(v.embedUrl)) return undefined;
const marks = howToSteps.filter((s) => s.clip).sort((a, b) => a.clip.start - b.clip.start);
const clips = marks.map((s, i) => ({
'@type': 'Clip',
name: s.clip.name,
startOffset: s.clip.start,
endOffset: i + 1 < marks.length ? marks[i + 1].clip.start : v.durationSeconds,
// Key moment URLs point at this page, where the video plays, with the
// start time as a query parameter.
url: `${pageUrl}?t=${s.clip.start}`,
})).filter((c) => Number.isInteger(c.endOffset) && c.endOffset > c.startOffset);
return compact({
'@type': 'VideoObject',
name: clean(v.name),
description: clean(v.description),
thumbnailUrl: [v.thumbnailUrl],
uploadDate: v.uploadDate,
duration: Number.isInteger(v.durationSeconds) ? `PT${Math.floor(v.durationSeconds / 60)}M${v.durationSeconds % 60}S` : undefined,
contentUrl: isUrl(v.contentUrl) ? v.contentUrl : undefined,
embedUrl: isUrl(v.embedUrl) ? v.embedUrl : undefined,
hasPart: clips.length >= 2 ? clips : undefined,
});
}
function buildRecipePageSchema(source) {
const site = source.site || {};
const page = source.page || {};
const r = source.recipe || {};
if (!isUrl(site.url) || !isUrl(page.url) || !r.name) return null;
const images = (r.images || []).filter(isUrl);
if (!images.length) return null;
const root = site.url.replace(/\/+$/, '');
const pageUrl = page.url;
const recipeId = `${pageUrl}#recipe`;
const howTo = steps(r.steps, pageUrl);
// Total time is stated only when it adds up: prep plus cook, or a total
// given on its own when there is no split.
const prep = duration(r.prepMinutes);
const cook = duration(r.cookMinutes);
const total = prep && cook ? duration(r.prepMinutes + r.cookMinutes) : duration(r.totalMinutes);
// Calories need a yield to mean anything: they are per serving.
const nutrition = Number.isFinite(r.caloriesPerServing) && r.servings > 0
? { '@type': 'NutritionInformation', calories: `${Math.round(r.caloriesPerServing)} calories` }
: undefined;
const rating = r.rating && r.rating.count > 0 && r.rating.value >= 1 && r.rating.value <= 5
? { '@type': 'AggregateRating', ratingValue: r.rating.value, ratingCount: r.rating.count }
: undefined;
const recipe = compact({
'@type': 'Recipe',
'@id': recipeId,
name: clean(r.name),
description: clean(r.description),
image: images,
author: isUrl(r.authorProfileUrl) ? { '@id': `${r.authorProfileUrl}#person` } : undefined,
publisher: { '@id': `${root}/#organization` },
datePublished: /^\d{4}-\d{2}-\d{2}/.test(r.datePublished || '') ? r.datePublished.slice(0, 10) : undefined,
prepTime: prep,
cookTime: cook,
totalTime: total,
recipeYield: r.servings > 0 ? [String(r.servings), `${r.servings} servings`] : undefined,
recipeCategory: clean(r.category),
recipeCuisine: clean(r.cuisine),
keywords: (r.keywords || []).filter((k) => k && k !== r.category && k !== r.cuisine).join(', ') || undefined,
recipeIngredient: (r.ingredients || []).map(clean).filter(Boolean),
recipeInstructions: howTo.map(({ clip, ...s }) => compact(s)),
nutrition,
aggregateRating: rating,
video: video(r.video, howTo, pageUrl),
mainEntityOfPage: { '@id': `${pageUrl}#webpage` },
});
const crumbs = breadcrumb(page.breadcrumbs, `${pageUrl}#breadcrumb`);
const webpage = compact({
'@type': 'WebPage',
'@id': `${pageUrl}#webpage`,
url: pageUrl,
name: clean(page.title) || clean(r.name),
isPartOf: { '@id': `${root}/#website` },
breadcrumb: crumbs ? { '@id': crumbs['@id'] } : undefined,
mainEntity: { '@id': recipeId },
});
return { '@context': 'https://schema.org', '@graph': [webpage, crumbs, recipe].filter(Boolean) };
}
{
"site": {
"url": "https://www.example.com"
},
"page": {
"url": "https://www.example.com/journal/one-pot-camp-chili/",
"title": "One-Pot Camp Chili | Harbor & Pine Journal",
"breadcrumbs": [
{
"name": "Home",
"url": "https://www.example.com/"
},
{
"name": "Journal",
"url": "https://www.example.com/journal/"
},
{
"name": "One-Pot Camp Chili",
"url": "https://www.example.com/journal/one-pot-camp-chili/"
}
]
},
"recipe": {
"name": "One-Pot Camp Chili",
"description": "A smoky bean and beef chili that cooks in one pot over a camp stove, ready in under an hour.",
"images": [
"https://www.example.com/img/journal/camp-chili-1x1.jpg",
"https://www.example.com/img/journal/camp-chili-4x3.jpg",
"https://www.example.com/img/journal/camp-chili-16x9.jpg"
],
"authorProfileUrl": "https://www.example.com/journal/authors/maya-okafor/",
"datePublished": "2026-08-21T08:00:00-04:00",
"prepMinutes": 15,
"cookMinutes": 40,
"servings": 4,
"caloriesPerServing": 520,
"category": "Main course",
"cuisine": "American",
"keywords": [
"camp cooking",
"one pot",
"Main course",
"backpacking"
],
"ingredients": [
"1 lb ground beef",
"1 onion, diced",
"2 cloves garlic, minced",
"2 tbsp chili powder",
"1 tsp smoked paprika",
"1 can (15 oz) black beans, drained",
"1 can (15 oz) crushed tomatoes",
"1 cup water",
"Salt to taste"
],
"steps": [
{
"name": "Brown the beef",
"text": "Brown the beef in the pot over medium heat, then pour off most of the fat.",
"videoStart": 0
},
{
"name": "Soften the onion",
"text": "Add the onion and garlic and cook for five minutes until soft.",
"videoStart": 75
},
{
"name": "Add spices and simmer",
"text": "Stir in the spices, beans, tomatoes and water, cover and simmer for thirty minutes.",
"videoStart": 150
},
{
"name": "Season and serve",
"text": "Season with salt, rest for five minutes off the heat and serve.",
"videoStart": 250
},
{
"text": "Enjoy!"
}
],
"rating": {
"value": 4.7,
"count": 86
},
"video": {
"name": "One-pot camp chili",
"description": "The whole recipe on a two-burner camp stove.",
"thumbnailUrl": "https://www.example.com/img/journal/camp-chili-video.jpg",
"uploadDate": "2026-08-21T08:00:00-04:00",
"durationSeconds": 312,
"embedUrl": "https://www.youtube.com/embed/example2"
}
}
}
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": "https://www.example.com/journal/one-pot-camp-chili/#webpage",
"url": "https://www.example.com/journal/one-pot-camp-chili/",
"name": "One-Pot Camp Chili | Harbor & Pine Journal",
"isPartOf": {
"@id": "https://www.example.com/#website"
},
"breadcrumb": {
"@id": "https://www.example.com/journal/one-pot-camp-chili/#breadcrumb"
},
"mainEntity": {
"@id": "https://www.example.com/journal/one-pot-camp-chili/#recipe"
}
},
{
"@type": "BreadcrumbList",
"@id": "https://www.example.com/journal/one-pot-camp-chili/#breadcrumb",
"itemListElement": [
{
"@type": "ListItem",
"position": 1,
"name": "Home",
"item": "https://www.example.com/"
},
{
"@type": "ListItem",
"position": 2,
"name": "Journal",
"item": "https://www.example.com/journal/"
},
{
"@type": "ListItem",
"position": 3,
"name": "One-Pot Camp Chili",
"item": "https://www.example.com/journal/one-pot-camp-chili/"
}
]
},
{
"@type": "Recipe",
"@id": "https://www.example.com/journal/one-pot-camp-chili/#recipe",
"name": "One-Pot Camp Chili",
"description": "A smoky bean and beef chili that cooks in one pot over a camp stove, ready in under an hour.",
"image": [
"https://www.example.com/img/journal/camp-chili-1x1.jpg",
"https://www.example.com/img/journal/camp-chili-4x3.jpg",
"https://www.example.com/img/journal/camp-chili-16x9.jpg"
],
"author": {
"@id": "https://www.example.com/journal/authors/maya-okafor/#person"
},
"publisher": {
"@id": "https://www.example.com/#organization"
},
"datePublished": "2026-08-21",
"prepTime": "PT15M",
"cookTime": "PT40M",
"totalTime": "PT55M",
"recipeYield": [
"4",
"4 servings"
],
"recipeCategory": "Main course",
"recipeCuisine": "American",
"keywords": "camp cooking, one pot, backpacking",
"recipeIngredient": [
"1 lb ground beef",
"1 onion, diced",
"2 cloves garlic, minced",
"2 tbsp chili powder",
"1 tsp smoked paprika",
"1 can (15 oz) black beans, drained",
"1 can (15 oz) crushed tomatoes",
"1 cup water",
"Salt to taste"
],
"recipeInstructions": [
{
"@type": "HowToStep",
"name": "Brown the beef",
"text": "Brown the beef in the pot over medium heat, then pour off most of the fat.",
"url": "https://www.example.com/journal/one-pot-camp-chili/#step-1"
},
{
"@type": "HowToStep",
"name": "Soften the onion",
"text": "Add the onion and garlic and cook for five minutes until soft.",
"url": "https://www.example.com/journal/one-pot-camp-chili/#step-2"
},
{
"@type": "HowToStep",
"name": "Add spices and simmer",
"text": "Stir in the spices, beans, tomatoes and water, cover and simmer for thirty minutes.",
"url": "https://www.example.com/journal/one-pot-camp-chili/#step-3"
},
{
"@type": "HowToStep",
"name": "Season and serve",
"text": "Season with salt, rest for five minutes off the heat and serve.",
"url": "https://www.example.com/journal/one-pot-camp-chili/#step-4"
}
],
"nutrition": {
"@type": "NutritionInformation",
"calories": "520 calories"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.7,
"ratingCount": 86
},
"video": {
"@type": "VideoObject",
"name": "One-pot camp chili",
"description": "The whole recipe on a two-burner camp stove.",
"thumbnailUrl": [
"https://www.example.com/img/journal/camp-chili-video.jpg"
],
"uploadDate": "2026-08-21T08:00:00-04:00",
"duration": "PT5M12S",
"embedUrl": "https://www.youtube.com/embed/example2",
"hasPart": [
{
"@type": "Clip",
"name": "Brown the beef",
"startOffset": 0,
"endOffset": 75,
"url": "https://www.example.com/journal/one-pot-camp-chili/?t=0"
},
{
"@type": "Clip",
"name": "Soften the onion",
"startOffset": 75,
"endOffset": 150,
"url": "https://www.example.com/journal/one-pot-camp-chili/?t=75"
},
{
"@type": "Clip",
"name": "Add spices and simmer",
"startOffset": 150,
"endOffset": 250,
"url": "https://www.example.com/journal/one-pot-camp-chili/?t=150"
},
{
"@type": "Clip",
"name": "Season and serve",
"startOffset": 250,
"endOffset": 312,
"url": "https://www.example.com/journal/one-pot-camp-chili/?t=250"
}
]
},
"mainEntityOfPage": {
"@id": "https://www.example.com/journal/one-pot-camp-chili/#webpage"
}
}
]
}
The output above was run through the SchemaCDN validator, which checks every rule in Google's documentation for each feature. It was validated together with the homepage graph, so the @id links resolve. Result: no errors, and eligible for:
The anchor every other page type points at: Organization, site name and store-wide policies.
ProductGroup with a priced Offer per variant, member pricing and links to store-wide policies.
Article with a real Person author, the Organization as publisher, video and a paywalled section.
One store location with address, geo and hours, linked to its parent brand.
One open role with pay, closing date and work location, linked to the employer and the store.
An in-store workshop with times, a rescheduled date, the store as venue and a free ticket.
A community thread that becomes Q&A or a discussion posting, depending on the thread.