DEV Community

Akbo Ichou
Akbo Ichou

Posted on

Recipe Structured Data Done Right: JSON-LD, ISO 8601 Durations and German Locale Gotchas

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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",
  };
}
Enter fullscreen mode Exit fullscreen mode

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, "");
Enter fullscreen mode Exit fullscreen mode

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)