Recipe sites live and die by rich results — the cards in search that show cook time, a photo and ingredients. Those come from Recipe structured data, and it's surprisingly easy to get subtly wrong, especially on a non-English site.
Beste Airfryer Rezepte is a German recipe site for air fryer (Heißluftfritteuse) and Thermomix recipes — chicken thighs, fries, salmon, schnitzel, goulash, risotto and more. Here's how to generate valid Recipe JSON-LD from one source of truth, plus the locale issues that bit me.
One recipe object, many outputs
Keep a single typed record per recipe and derive both the visible page and the structured data from it. That way they can't drift apart:
interface Recipe {
slug: string;
name: string; // "Hähnchenschenkel im Airfryer"
description: string;
image: string[];
prepMinutes: number;
cookMinutes: number;
servings: number;
device: "airfryer" | "thermomix";
tempC?: number;
ingredients: string[]; // human-readable, already localized
steps: { name?: string; text: string }[];
category: string; // "Hauptgericht"
cuisine?: string; // "Deutsch"
}
Durations must be ISO 8601
prepTime, cookTime and totalTime aren't "40 Min." — they're ISO 8601 durations like PT40M. A tiny helper avoids hand-written strings:
export function isoDuration(minutes: number): string {
const h = Math.floor(minutes / 60), m = minutes % 60;
return `PT${h ? `${h}H` : ""}${m || !h ? `${m}M` : ""}`;
}
isoDuration(35); // "PT35M"
isoDuration(90); // "PT1H30M"
Generating the JSON-LD
export function recipeJsonLd(r: Recipe, url: string) {
return {
"@context": "https://schema.org",
"@type": "Recipe",
name: r.name,
description: r.description,
image: r.image,
inLanguage: "de-DE",
prepTime: isoDuration(r.prepMinutes),
cookTime: isoDuration(r.cookMinutes),
totalTime: isoDuration(r.prepMinutes + r.cookMinutes),
recipeYield: `${r.servings} Portionen`,
recipeCategory: r.category,
recipeCuisine: r.cuisine,
recipeIngredient: r.ingredients,
recipeInstructions: r.steps.map((s, i) => ({
"@type": "HowToStep",
position: i + 1,
name: s.name,
text: s.text,
url: `${url}#schritt-${i + 1}`,
})),
tool: r.device === "airfryer" ? "Heißluftfritteuse" : "Thermomix",
};
}
Render it in a <script type="application/ld+json"> tag, serialized with JSON.stringify — never string-concatenated, or a quote in a recipe name will break the whole block.
German locale gotchas
-
Decimal commas. "1,5 EL Öl" is correct German. Keep ingredient strings localized for display, but store quantities as numbers if you ever scale recipes, and format with
Intl.NumberFormat("de-DE"). -
Umlauts and ß in slugs. Decide once how to transliterate:
hähnchenschenkel→haehnchenschenkel,groß→gross. Inconsistent slugs create duplicate URLs. - Units. EL/TL (tablespoon/teaspoon), g, ml and °C — no cups or °F. Mixing them in confuses readers and search engines alike.
const slugify = (s: string) =>
s.toLowerCase()
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
Air fryer specifics belong in the content
Air fryer recipes depend heavily on temperature, basket loading and when to shake or flip. Those details go into the step text (and name for each step), so they show up both on the page and in structured data — e.g. "Nach 10 Minuten wenden".
Validate in CI
Run every generated recipe through a schema check in CI, so a missing image or malformed duration fails the build instead of silently losing rich results.
Takeaways
- Generate JSON-LD from the same record as the page.
- Use ISO 8601 durations and
JSON.stringify. - Handle decimal commas, umlauts and metric units deliberately.
- Validate structured data automatically.
Find the recipes at beste-airfryer-rezepte.de. Have you run into structured-data issues on non-English sites?
Top comments (0)