Structured data is write-only code. You add a node, a validator says valid, and then nobody reads it again, because the consumer is a crawler and the feedback arrives months later as a shape in a search result, or as nothing at all.
So I read ours. I requested all 516 URLs in munchable.app/sitemap.xml, pulled every <script type="application/ld+json"> block out of each one, parsed them, flattened the @graph, and counted the type tuples. Here is the entire site, sorted by how many pages have each shape:
461 Organization WebSite BreadcrumbList Article FAQPage ingredient answer pages
38 Organization WebSite Organization BreadcrumbList Recipe recipes
9 Organization WebSite Organization BreadcrumbList Article FAQPage
8 condition guides and 1 reference page
3 Organization WebSite privacy, terms, licences
2 Organization WebSite BreadcrumbList ItemList /recipes and /conditions
1 Organization WebSite MobileApplication FAQPage the homepage
1 Organization WebSite BreadcrumbList AboutPage /about
1 Organization WebSite BreadcrumbList /answers
Eight shapes, 516 pages, and two things in that table that I did not know before I printed it.
One publisher, two nodes, one @id
Rows two and three say Organization twice. That is 47 pages: the 38 recipes, the 8 condition guides, and the hidden-gluten reference page. The two nodes are byte-identical, 397 bytes each, and they share an identifier:
{"@type":"Organization","@id":"https://munchable.app/#organization", ... }
The cause is two reasonable decisions meeting in one document. The root layout publishes the site-wide identity on every page in the site:
<JsonLd data={jsonLdGraph(organizationLd(), webSiteLd())} />
And a page template builds its own graph, in which an Article needs a publisher to point at, so it includes one:
<JsonLd data={jsonLdGraph(
organizationLd(), // this line
breadcrumbLd([{ name: 'Home', path: '/' }]),
{ '@type': 'Article', publisher: { '@id': ORGANIZATION_ID }, ... },
faqLd(FAQS),
)} />
Whoever wrote that was right that an Article wants a publisher node to reference, and wrong that this page has to supply it, because the layout supplied it one script tag earlier in the same document.
The 461 generated answer pages get this right, and they get it right by accident of chronology: they were written later, their Article references the @id without carrying the node, and nobody went back to notice that the three older templates do both.
Is a duplicated @id actually a problem?
Mostly not, and I would rather say so than inflate it into one. Consumers merge nodes by @id, and merging two identical nodes is a no-op. The byte cost is 397 times 47, which is 18 KB across the whole site and effectively zero after compression.
What it costs is the only thing structured data is for, which is giving one answer to "who published this". We give it twice on 47 pages. Somewhere two commits into the future there is a version of this repository where somebody updates the publisher in one of the two places, and then a page makes two claims under one identifier and the merge order picks a winner. That is not a bug today. It is a bug stored for later, and an audit is how you find the stored ones.
The fix is deleting one argument in three files.
The index page that indexes nothing
Now look at the last four rows. /recipes and /conditions each carry an ItemList, which is the node that says this page is a list of these things, in this order. Each of those pages lists a few dozen items.
/answers is the index for 461 pages, and its entire graph is a breadcrumb.
That is the finding I did not expect, and it is the one I would fix first. An ItemList on a hub page is not decoration. It is the machine-readable claim that these URLs belong to this page and form a set rather than a pile, and it is the difference between a hub and a page that merely contains links. The hubs for our two small collections have it. The hub for the collection that is 89 per cent of our URLs does not, for no reason at all except that the three pages were written on three different days.
While I was in there: the three legal pages carry no breadcrumb and no page-type node whatsoever. That one is fine, and I am not going to add anything. Nothing good happens to a terms of service in a search result, and a WebPage node whose entire content is "this is a page" earns its bytes nowhere.
The rule that matters more than any of this
There is one structured data rule I care about far more than completeness: no node may claim more than the page shows. Markup describing content a reader cannot see is the one mistake in this area with a manual action behind it, and it is easy to commit by accident, because the markup and its subject live in different parts of the file and drift apart quietly.
So the scan checked it. 471 of the 516 pages carry an FAQPage node, 491 question and answer pairs between them. All 491 questions appear verbatim in the visible text of the page they are published on. Only 30 of the 491 answers do.
That number stopped me, and the explanation turned out to be worth more than the finding. On the 461 generated pages the markup writes an answer as prose beginning with the verdict as its own sentence, while the page renders the verdict as a styled lead and the prose after it. In the markup: "No. Munchable reads ale as beer, and ...". On the page: "No, and here is why", then "Munchable reads ale as beer, and ...". Drop the first sentence from every answer and all 491 are present, word for word, the 461 included.
So the naive check reports 461 failures and every single one is cosmetic. That is not an argument against the check. It is the argument for running it on your own site before you trust it, because a raw string comparison between markup and rendered text flags every place where the same sentence is assembled differently in two files, and a real mismatch would be sitting in that noise wearing the same colour.
That is the check worth putting in CI, and it is worth more than any node you could add. A question in your markup that is not on your page is a claim about a page that does not exist.
Auditing your own takes fifteen lines
Fetch your sitemap. Fetch every URL in it. Regex out the ld+json blocks, json.loads each one, flatten @graph, print a sorted count of the type tuples. Use four workers and a retry, because requesting 516 pages of your own site as fast as your laptop can manage will get you a connection reset, as I found out.
The useful output is not a validator verdict. It is the table at the top of this post, because a table is where you see that three pages which ought to be the same shape are three different shapes.
Ours came to one duplicated node, one hub missing a list, and one rule holding. If you want to read the real thing rather than take my word for it, view source on a condition guide and search for ld+json. There are two blocks on 513 of our pages and one on the other three, and in the second block there is an old decision waiting to be deleted.
Top comments (0)