---
title: "Recipe schema markup in the wild: what sites really send — Tiny Plates"
description: "Recipe schema markup as real sites write it: odd durations, HTML in ingredients, free-text yields, junk steps. Plus what good recipe JSON-LD looks like."
url: "https://www.tinyplates.dev/blog/recipe-schema-markup-in-the-wild"
---

# What recipe sites really put in their schema.org markup

A tour of the odd JSON-LD real recipe pages send, and what good markup looks like

24 October 2026

Most recipe sites have a little block of JSON hidden in their HTML. It's schema.org `Recipe` markup, usually as JSON-LD, and it's there so Google can show the recipe with a photo, stars and a cooking time. For anyone building with recipes, it's a gift. You get the title, ingredients, steps, times and yield without scraping a single `<div>`.

In theory. Tiny Plates is built on that markup. Our pipeline reads it from public recipe pages and turns it into one clean model. Every odd case it handles is there because a real page did it. So this post is a tour of what recipe sites actually put in their schema.org markup. Some examples come from our normalizer's code and tests. The numbers come from a fresh sample we took this week.

Here's how we counted. We fetched 459 recipe pages from 24 of the sites our pipeline reads, about 20 pages from each. Five more sites blocked us, which is fair enough. 399 of the pages had a Recipe in their JSON-LD, from 22 sites. This isn't a random sample of the web. The sites lean English and lean WordPress. And most quirks come from a site's template, so a quirk tends to show up on every page of one site. That's why we'll often count sites, not just pages.

## Finding the recipe is the first puzzle

You'd think you just look for `"@type": "Recipe"`. And sometimes you can. But the Recipe object turns up in at least four places: at the top level, inside a top-level array, inside an `@graph` next to the page and the organization, or under a `mainEntity`. Then `@type` might be a string, or an array like `["Recipe", "NewsArticle"]`, or written with its vocabulary as `"schema:Recipe"` or `"https://schema.org/Recipe"`.

So the finder ends up looking like this:

```ts
function findRecipe(data: unknown): Record<string, unknown> | null {
  if (Array.isArray(data)) {
    for (const item of data) {
      const found = findRecipe(item)
      if (found) return found
    }
    return null
  }
  if (typeof data !== 'object' || data === null) return null

  const node = data as Record<string, unknown>
  const types = [node['@type']].flat()
  if (types.some((t) => typeof t === 'string' && t.replace(/^(https?:\/\/schema\.org\/|schema:)/i, '') === 'Recipe')) {
    return node
  }
  return findRecipe(node['@graph']) ?? findRecipe(node.mainEntity)
}

```

In our sample, 56% of recipes sat in an `@graph`, 35% at the top level and 10% inside an array. None used `mainEntity`, and every one wrote `@type` as a plain "Recipe". So the odd forms are rarer than the code makes them look. But they're out there, and handling them costs nothing.

Some pages have several JSON-LD blocks, and some of those blocks aren't valid JSON. 15% of the pages we fetched had a block that `JSON.parse` rejects. Most were just empty `<script>` tags. One site wraps its block in a CDATA comment, and another puts raw line breaks inside its strings. We skip the broken ones and keep looking. And in an `@graph`, the author is often just a pointer, like `{ "@id": "https://site/#/schema/person/1" }`. Yoast does this. Half the `@graph` recipes we saw (109 of 222) named their author that way. Take the recipe out of its graph and that pointer means nothing, so we look up the person's name while the graph is still in hand.

## The fields, one mess at a time

**Text is full of HTML.** Ingredient lines arrive as `2 <b>tbsp</b> butter &amp; oil`. Steps arrive with `&lt;strong&gt;Mix&lt;/strong&gt; well`, which is a tag once you decode it. And a surprising number of sites encode things twice. `1 &amp;frac12; cups` is "1 ½ cups" after two rounds of decoding. Our cleaner decodes twice for exactly this reason. This week's ingredient lines were cleaner than we expected: no HTML tags at all, though 7 of the 22 sites still sent entities like `&amp;`. Steps were worse. 13 sites had entities in their step text, and one French site double-encodes every accent, so "à" arrives as `&amp;agrave;`. It also turns the fraction slash in "1⁄2" into a plain "/", because otherwise "1⁄2" isn't a number to anything downstream.

**Ingredients aren't always a list.** `recipeIngredient` should be an array of strings. Sometimes it's one string with every line split by a newline or a `<br>`. Older markup uses `ingredients` instead. And some recipe plugins put notes in double brackets with slashes inside, so you get lines like `1 (15-oz) can white beans ((drained and rinsed // we like cannellini))`. Section headings show up as ingredients too: "FOR THE SAUCE:", "Topping", "Fyllning". Three of our 22 sites did it this week, with lines like "CUSTARD:" and "For the Pastry Cream:". One Swedish site writes "ingen enhet" ("no unit") in front of lines without an amount, like `ingen enhet rostad lök`. We wrote a whole post on [parsing ingredient lines](https://www.tinyplates.dev/blog/parse-ingredient-lines), so we'll leave those there.

**Times are ISO 8601\. Mostly.** `PT30M` and `PT1H30M` are easy, and good news: 93% of the total times in our sample looked just like that. 14% of pages wrote minutes past the hour, like `PT90M` or `PT240M`. That's valid, just not what a quick parser expects. Then some plugins write every field, so you get `P0Y0M0DT0H35M0.000S` for 35 minutes. Some send a bare `P` or `PT` when there's no time at all. We didn't see either this week, but real pages taught our normalizer both. Four sites sent `PT0M`, on 6% of pages. That isn't a recipe that takes no time. It's a site saying nothing. We read zero as "not given". And then there's free text like `30 minutes` in a field that wants a duration.

Even valid times can be wrong. One Swedish cheesecake recipe says 15 minutes of prep and a total time of one minute. When the total is shorter than prep plus cook, we add the parts up instead. That's rare: 2 of the 283 pages that gave all three times. Another 40 had a total longer than prep plus cook. That's usually resting time, so don't "fix" those. A German site we read leaves the times out of the markup entirely. Instead it adds a last "step" that says `Zubereitungszeit ca. 1-1 1/4 Stunden` (prep time about 1 to 1¼ hours). We take that out of the steps and use it as the total.

**Steps come in every shape.** `recipeInstructions` can be a list of `HowToStep` objects, `HowToSection`s that hold steps, plain strings, `ListItem`s wrapping a step, or one long string with "1." and "2." in it. In our sample, 73% of pages sent a clean list of `HowToStep`s. The rest is where it gets fun. We've seen:

* Every step's `name` set to "Schritt 1", "Schritt 2". Or the step text copied into the `name`. Of the 12 sites that name their steps, 7 copy the whole text in, word for word.
* The whole method as one HTML string, with `<h3>` headings and an `<ol>` inside. One site did it on every page. Another writes its steps in Markdown.
* Section headings sent as steps of their own, like "Buljong" or "Pizzadeg".
* A step cut in half mid-sentence: "…Strö ca hälften av" and then a new step that starts "blandningen på riset."
* The same step twice in a row.
* Things that aren't steps at all: "Foto: Först,", a credit for the baking dish ("Auflaufform: Marchant"), or "Ca 510 kcal per portion: 18 g fett • 20 g protein" glued onto the last step.
* "Notes", "Tips" and "Storage" sections marked up as more cooking steps. We move those to the recipe's notes.

**Yield is free text.** `recipeYield` might be `4`, `"4"`, `"Serves 4-6"`, `"Makes 24 cookies"`, `"4 portioner"`, `"Pour 4 personnes"` or `"1 dozen"`. Or an array with the bare number and a sentence. Half the pages in our sample sent an array, mostly a WordPress plugin's `["4", "4 servings"]`. A quarter sent free text, like "4 port", "ca 20 st", "12 Stück" or "4-1/2 dozen". Only 16% sent a plain number. Then the sentences get creative. Real ones from our tests:

```text
"2 enormous celebration centrepieces"
"4 to 6 as a main course, 8 as a side dish"
"Two 9-inch pies (about 12 servings total)"
"makes about 6 cups - about 12 half cup servings"
"A big batch"
"0"

```

That "9-inch" is a pan size, not nine servings. "6 cups" is a volume. So the reader skips numbers that measure something, prefers the one next to a word like "servings", and keeps the original string either way.

**Nutrition is strings with units in them.** Every page in our sample that had nutrition (74% of them) wrote its values that way. Two thirds said "kcal" and one third "calories". You get `"250 calories"`, `"10 g"`, `"610mg"`, `"2,5 g"` with a decimal comma, and `"1 046 kJ"` with a thin space between the thousands. That one has to become 250 kcal. Micrograms are written with the micro sign µ or the Greek letter μ, which look the same and aren't. Sometimes a macro is labeled in IU, which only makes sense for vitamins. And sometimes the whole `nutrition` field is just the string `"none"`.

**Images are an array of crops.** `image` can be a URL, an `ImageObject`, or an array of either. 60% of the pages in our sample sent an array. And every array with more than one image, 211 of them across 13 sites, was the same photo in different crops. Like this: `tikka-1x1.jpg`, `tikka-4x3.jpg`, `tikka-16x9.jpg`. Or `?resize=1200,900` next to `?resize=600,400`. That's actually what Google asks for, so it's fine. It just means the first image is the hero and the rest are often copies of it, not a gallery. Some sites send `"image": "not-a-url"` or a protocol-relative `//cdn...` path.

**Categories hold everything.** `recipeCuisine` holds "Vegan" as often as it holds "Italian". `recipeCategory` arrives as one comma-separated string. Diet labels come with hedges like "Gluten-Free (optional)", which means the recipe isn't gluten-free. "Mexican-Inspired" should be Mexican. A keyword "turkey" is usually the bird, not the country. And "entree" means the main course in American English and the starter in French.

**Ratings aren't always numbers.** Most are fine. 71% of our sample sent `ratingValue` as a string like `"4.8"`, the rest as a number, and none sent words. But we've had `"ratingValue": "great"`. Rating counts come as `"1,234"`, `"1.234"` or `"1 234"`.

**Language is often missing.** `inLanguage` is optional, and almost everyone leaves it out. Only 1 of the 22 sites in our sample set it. That's 5% of pages. That matters more than you'd think. "1,5 dl" is one and a half in Swedish. "1,000 g" is a thousand in English. It's also why we keep [ingredient lines in the recipe's language](https://www.tinyplates.dev/blog/ingredient-lines-in-the-recipes-language).

One more number, this one from our own data. One big Swedish food site wraps every recipe's steps in a `HowToSection` named "Första instruktionen" ("First instruction"). It names nothing. In one snapshot of our development data, that single name was 1,351 of the 1,840 step groups we'd stored. One site's habit, three out of four section names. That's why we now ignore generic section names like "Instructions", "Zubereitung" and "Första instruktionen".

None of this is a dig at food bloggers. Most of it comes from plugins, themes and CMS defaults that the writer never sees. The recipe on the page is fine. The JSON is just a little off.

## What good Recipe markup looks like

If you write recipes, here's the target. Google's [recipe structured data guide](https://developers.google.com/search/docs/appearance/structured-data/recipe) only requires `name` and `image`. But it recommends a lot more, and everything below is on its list:

```json
{
  "@context": "https://schema.org",
  "@type": "Recipe",
  "name": "Weeknight Chicken Tikka Masala",
  "image": [
    "https://example.com/photos/tikka-1x1.jpg",
    "https://example.com/photos/tikka-4x3.jpg",
    "https://example.com/photos/tikka-16x9.jpg"
  ],
  "author": { "@type": "Person", "name": "Jane Doe" },
  "datePublished": "2026-03-10",
  "description": "A creamy, mild tikka masala that's on the table in 45 minutes.",
  "inLanguage": "en",
  "prepTime": "PT15M",
  "cookTime": "PT30M",
  "totalTime": "PT45M",
  "recipeYield": "4",
  "recipeCategory": "Dinner",
  "recipeCuisine": "Indian",
  "keywords": "chicken, curry, weeknight",
  "recipeIngredient": [
    "500 g boneless chicken thighs, cut into chunks",
    "200 ml double cream",
    "2-3 large tomatoes, diced",
    "salt to taste"
  ],
  "recipeInstructions": [
    { "@type": "HowToStep", "text": "Marinate the chicken for 30 minutes." },
    { "@type": "HowToStep", "text": "Simmer in the sauce until cooked through." }
  ],
  "nutrition": { "@type": "NutritionInformation", "calories": "520 calories" }
}

```

And a few tips that would make every scraper, search engine and AI tool read you right:

* **Use `HowToStep` for every step.** Google recommends it over plain text. Put only the instruction in `text`. Leave "Step 1" labels, photo credits and nutrition out of it.
* **Only use `HowToSection` for real sections**, like "For the dough". Not as a wrapper around the whole method.
* **One ingredient per array item.** Plain text, no HTML, and no headings in the list. If your plugin supports ingredient groups, use them.
* **Real ISO 8601 durations.** `PT45M`, not "45 minutes". Leave a time out rather than sending `PT0M`. Check that the total isn't shorter than prep plus cook.
* **`recipeYield` as a number of servings.** Google says that's required if you include `nutrition.calories`. Put "makes 24 cookies" in the description if you need it.
* **Set `inLanguage`.** It costs one line and saves everyone a guess.
* **Use `suitableForDiet` for diets**, not `recipeCuisine`. And only if the recipe really is that, not "with a swap".
* **Check it.** Google's Rich Results Test will flag a lot of this. View your own page source once, too. You might be surprised what your plugin writes.

If you're on the other side, building something that reads this markup, plan for every case above. Keep the raw JSON-LD you scraped. We store it next to every recipe, so when we fix a rule we can re-run it over everything without fetching a single page again. And keep the original text next to whatever you parse out of it.

Or skip the cleanup. Tiny Plates does all of this already and gives you one consistent recipe object: clean times, servings as numbers, steps numbered, ingredients parsed with canonical ids. There's [a field-by-field reference](https://www.tinyplates.dev/docs/recipe-object), and every recipe links back to its source. If you're weighing that against raw data, we also looked at [free recipe datasets](https://www.tinyplates.dev/blog/free-recipe-datasets). The free plan is 50 requests a day with no credit card. [Grab a key](https://www.tinyplates.dev/sign-up) if you'd rather spend your time on the app than on `P0Y0M0DT0H35M0.000S`.
