491 FAQ questions in our markup, and 461 of them are the only question on their page
Munchable publishes 516 pages. Every one of them carries structured data, and until I counted it I could not have told you what shape it was. Nine top-level types, across the whole site: Organization, WebSite, MobileApp
Munchable publishes 516 pages. Every one of them carries structured data, and until I counted it I could not have told you what shape it was.
Nine top-level types, across the whole site: Organization, WebSite, MobileApplication, BreadcrumbList, ItemList, Article, AboutPage, Recipe, FAQPage. 513 pages carry two <script type="application/ld+json"> blocks and three carry one. And the single biggest population in all of it is a FAQPage containing exactly one question.
Here is how to see it on any page of the site:
$ curl -s https://munchable.app/recipes/gentle-rice-congee \
| grep -o '"@type":"[A-Za-z]*"' | sort | uniq -c | sort -rn
5 "@type":"HowToStep"
4 "@type":"Organization"
2 "@type":"ListItem"
1 "@type":"WebSite"
1 "@type":"Recipe"
1 "@type":"NutritionInformation"
1 "@type":"BreadcrumbList"
Four Organizations on one recipe page. Two of them are Munchable, declared twice, and two are the parent company nested inside those. We will come back to that.
The one-question FAQ
461 of the 516 URLs are pages that answer a single question about a single ingredient, with the question in the URL. Each one emits this:
faqLd([{ q: page.ask, a: `${lead}. ${page.detail}` }])
One entry. The temptation with FAQPage is to treat it as a slot you fill, and the easy fill is a block of five generic questions dropped onto every page in the family. 461 pages would then carry 2,305 questions between them, of which 2,300 would be the same five questions repeating, and the one question the page exists to answer would be one voice in a chorus.
So the count across the site is small and lopsided:
| Where | Pages | Questions |
|---|---|---|
| Ingredient answer pages | 461 | 1 each |
| Condition guides | 8 | 2 to 5 each, 22 total |
| Hidden gluten reference | 1 | 3 |
| Homepage | 1 | 5 |
491 questions, 94 per cent of them alone on their page. The 30 that share a page are on pages that genuinely have several things to say, and they are the on-page FAQ rendered as markup rather than markup invented for a crawler. That last distinction is the whole game with FAQPage: the question and answer in the JSON exist as visible text in the document, every time. There is no second set of answers written for machines.
Two graphs, and the same Organization twice
The root layout emits one graph on all 516 pages:
jsonLdGraph(organizationLd(), webSiteLd())
and most pages emit a second one of their own. On 47 of them, that second graph starts with organizationLd() again, because a Recipe or an Article wants an author and a publisher and the natural way to write that node is to include the thing it refers to.
Declaring the same entity twice in one document is fine, and the reason is one property:
export function organizationLd() {
return {
'@type': 'Organization',
'@id': ORGANIZATION_ID, // https://munchable.app/#organization
name: SITE_NAME,
// ...
};
}
A consumer merges nodes that share an @id, so two identical Organization nodes are one entity mentioned twice rather than two companies with the same name. The @id is also what lets every other node refer to it without repeating it:
{ "@type": "Recipe", "author": { "@id": "https://munchable.app/#organization" }, ... }
Without the @id convention, either every node inlines the full Organization (and a crawler has to guess whether those are the same company) or the references dangle. With it, the duplicate is free and the references are cheap. The one thing to be careful of is that an @id reference is only resolvable if the real node is somewhere in the same document, which here is guaranteed by the layout block that nobody has to remember.
470 Article nodes, two shapes
The guides and the answer pages are both Article, and they are not the same Article.
An answer page's article has no author and no publisher. It has about:
{
"@type": "Article",
"headline": "Is soy sauce gluten free?",
"inLanguage": "en-GB",
"about": [
{ "@type": "Thing", "name": "Soy sauce" },
{ "@type": "MedicalCondition", "name": "Coeliac disease" }
]
}
Two nouns, and the page is the intersection of them. MedicalCondition is the part worth typing by hand: it is the difference between a page a crawler reads as being about a word and a page it reads as being about a condition, and it is the only vocabulary in schema.org that says so. 461 pages carry a pair like that. The nine guide and reference pages carry publisher instead and no about, because a guide is about its own subject and saying so adds nothing.
The property our Recipe nodes do not have
38 recipe pages emit a Recipe node with the name, the description, totalTime as an ISO 8601 duration, recipeYield, the categories, the keywords, every ingredient line, every step as a numbered HowToStep, and a NutritionInformation. It is complete except for one property, and that property is image.
image is required for a Recipe rich result. Leaving it out means these pages are ineligible for the carousel, the thumbnail, the little clock with the cooking time in it. That is a real cost, accepted deliberately, because Munchable has no food photography and the available fix is not photography. It would be a generated typographic card, rendered at 1200 by 630, declared as a picture of the dish. Markup that overstates what the page contains is a manual-action risk, and a type specimen passed off as a photograph of congee is squarely that. The day there are photographs it is a one-line change.
suitableForDiet is absent for a different reason. Its vocabulary would invite a free-from claim inferred from an ingredient list, and a free-from claim is not something this product makes anywhere, least of all in machine-readable form where nobody reads the sentence of caveat sitting next to it on the page.
What is there is modest and derived:
"nutrition": {
"@type": "NutritionInformation",
"servingSize": "178 g",
"fatContent": "8 g",
"fiberContent": "1 g"
}
Three fields, all three computed from the quantities printed above them on the page, which is the only reason they are safe to publish. A calorie figure would have to be asserted, and an asserted number in structured data is a promise made to a machine on behalf of a kitchen.
One priceSpecification that exists for one word
The homepage carries MobileApplication with two Offer nodes, the free tier and Premium. The free one is three lines. Premium has a nested UnitPriceSpecification, and the only reason it exists is this:
"priceSpecification": {
"@type": "UnitPriceSpecification",
"price": "10.00",
"priceCurrency": "GBP",
"valueAddedTaxIncluded": true,
"billingIncrement": 1,
"unitText": "MONTH"
}
valueAddedTaxIncluded has no home on a plain Offer. Premium is sold tax inclusive, so the figure in a search snippet is the whole figure, and the markup should be able to say that rather than leaving a crawler or a comparison site to assume tax comes on top of it.
The escaping is not optional
One component renders every one of these blocks:
const json = JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
JSON.stringify does not escape <, and the contents of a <script> element are not parsed as HTML text, so a string containing </script> closes the tag early and everything after it becomes markup. On a site where all the copy is written by hand this is theoretical. Munchable's structured data includes product names and ingredient text that came from a photograph of a label, so it is not: the input is a real person pointing a camera at a packet. Three replacements, done once in the only place that writes a script tag, rather than trusted to whoever adds the next node type.
See it yourself
Pick any page and read its graph:
$ curl -s https://munchable.app/is-soy-sauce-gluten-free \
| grep -o '"@type":"[A-Za-z]*"' | sort | uniq -c | sort -rn
2 "@type":"Organization"
2 "@type":"ListItem"
1 "@type":"WebSite"
1 "@type":"Thing"
1 "@type":"Question"
1 "@type":"MedicalCondition"
1 "@type":"FAQPage"
1 "@type":"BreadcrumbList"
1 "@type":"Article"
1 "@type":"Answer"
Counting with grep like that double counts, because a nested node has a @type too, which is exactly how one page ends up looking like it has four companies on it.
The pages worth comparing are an answer page with its single question, a condition guide with a handful, a recipe with everything but the picture, and the recipe index with its 38-item ItemList. Then open terms, which has one block and nothing else, because a legal page is not about anything a crawler can use.
The pattern I would take to the next site is the lopsided one. 94 per cent of the questions in this markup are alone on their page, and that is not a limitation of the generator. It is what happens when the rule is that the markup may only describe what the page already says.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.