DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our Recipe structured data leaves out the one property Google requires, and that is the decision

Here are the keys in the Recipe node on a live Munchable recipe page, pulled out of the page source:

['@id', '@type', 'author', 'description', 'inLanguage', 'keywords', 'name',
 'nutrition', 'publisher', 'recipeCategory', 'recipeIngredient',
 'recipeInstructions', 'recipeYield', 'totalTime', 'url']
Enter fullscreen mode Exit fullscreen mode

No image. Google's Recipe documentation lists image under Required properties: "Image of the completed dish." Without it the page is not eligible for a recipe rich result, full stop. Everything else in that list is there, so this is one property away from the carousel.

The property is missing because there is no photograph. Munchable has no food photography, and the fix for that is a camera, not markup.

The tempting version of the fix

The tempting fix is the one that is already half built. The site has a design system, a warm palette and a typographic card for every recipe, and generating a 1200x630 PNG per recipe with the dish name set in the brand face is an afternoon of work. It would satisfy the validator. It would also be a picture that is not of the dish, submitted to a feature whose own guideline says "images must represent the marked up content".

Structured data that overstates the page is a manual action risk rather than a ranking trick, and "we told Google this card was a photo of the food" is precisely the overstatement the guideline is aimed at. So the comment above the builder says the field is deliberately missing, and the day there are photographs it is a one line change.

The second absence is the more interesting one

suitableForDiet is also absent, and that one is not about photography at all.

The schema.org vocabulary is inviting: GlutenFreeDiet, LowLactoseDiet, LowFodmapDiet and friends. A recipe page already knows which conditions it suits, so filling it in reads like free metadata.

It would also be a free-from claim. Munchable does not make those anywhere in its interface, because a claim like "gluten free" is a legally defined threshold about a finished product, and what a recipe page knows is which ingredients somebody typed. In the interface, every such statement sits next to a caveat about reading the physical label. In a JSON-LD blob there is no room for the caveat, no reader to see it, and the consumer is a machine that will repeat the claim in a result card.

A property you refuse to emit is easier to defend than a property you emit carefully.

What is published is derived, not asserted

The nutrition node is three fields: servingSize, fatContent, fiberContent, and they are computed from the ingredient quantities on the page rather than typed in next to them. That is the whole reason it is safe to publish. Nothing in there is a number somebody felt was about right, and there is no calories, because nothing on the page computes one.

The breadcrumb bug this review found

While writing this I went and read the live markup, which is how I found something I did not know.

Our breadcrumb builder takes the trail of ancestors and deliberately leaves out the current page:

/** Trail of ancestors, NOT including the current page's own link. */
export function breadcrumbLd(items: ReadonlyArray<{ name: string; path: string }>) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

A recipe detail page passes Home and Recipes, which is two items. Google's breadcrumb documentation says to "define a BreadcrumbList that contains at least two ListItems", so two is exactly the minimum and the detail pages are fine.

The index pages are not. /recipes and /answers each pass a single ancestor, Home, so they publish a BreadcrumbList with one item:

{"@type": "BreadcrumbList", "itemListElement": [
  {"@type": "ListItem", "position": 1, "name": "Home", "item": "https://munchable.app/"}
]}
Enter fullscreen mode Exit fullscreen mode

That is below the documented minimum, which makes it markup that does nothing. The fix is the convention I had explicitly rejected in a comment: include the current page as the final ListItem. It is allowed, it is in Google's own example, and the final item does not need an item URL. The "ancestors only" rule turns out to be right for a leaf page and wrong for a section index, which is the kind of thing you only notice by reading the output rather than the function.

One array for the FAQ, two consumers

The condition pages carry an FAQPage node, and it is built from the same array the page renders:

faqLd(page.faqs)
...
{page.faqs.map((faq) => ( /* the visible accordion */ ))}
Enter fullscreen mode Exit fullscreen mode

There is no second copy of the questions for crawlers. That is the only version of FAQ markup I am willing to ship, because the failure mode of hand-maintained FAQ structured data is markup that promises an answer the page stopped giving three edits ago.

Read the source rather than my description of it

All of this is view-source-able. munchable.app/recipes/gentle-rice-congee is a recipe page, and the Recipe node in it is the one with the missing image. munchable.app/conditions/low-fodmap-ibs is a condition guide, where the FAQPage node and the visible questions at the bottom of the page come out of one array. munchable.app/recipes is the index with the one item breadcrumb, which will not be one item for very much longer.

Everything in those documents is one @graph with @id references, so within a page the Organization node is written once and pointed at by the Article, Recipe and publisher slots instead of being inlined three times. Across pages it comes out of a single builder, which is the part that stops it drifting.

Top comments (0)