Structured data has one rule that matters more than the schema docs: it must describe what is visibly on the page. Everything else is formatting.
The way that rule gets broken is rarely malice. It is a FAQPage node listing six questions when the page shows four, because somebody edited the copy and not the JSON. So on our site every builder takes the same data the page renders from, and the two cannot drift apart without somebody deliberately passing different arguments.
Here is what that looks like from outside. Count the question and answer nodes in the markup, then count the questions on the page:
curl -s https://pub-trivia.app/faq | grep -c '"@type":"Question"'
# 16
curl -s https://pub-trivia.app/faq | grep -c '<dt'
# 16
Same array. One rendered as a definition list, one serialised into a script tag.
A page gets one FAQPage node, however many FAQ blocks it has
Our FAQ is grouped under several headings, each rendered by the same component. The naive version of that emits one FAQPage per group, and then a single page is carrying four nodes that each claim to describe the whole page.
So the component takes a flag:
<FaqBlock heading={group.heading} entries={group.entries} emitStructuredData={false} />
and the page emits one node built from every group concatenated:
<JsonLd data={faqLd(ALL_ENTRIES)} />
The flag defaults to on, because a page with one FAQ block is the common case and should not have to think about it. FAQPage is a page-level claim, and the only safe place to make a page-level claim is the page.
HowTo only where the page genuinely is a procedure
We have fourteen guides. One of them emits HowTo:
curl -s https://pub-trivia.app/guides/how-to-host-a-pub-quiz | grep -c '"@type":"HowTo"'
# 1
curl -s https://pub-trivia.app/guides/pub-quiz-round-ideas | grep -c '"@type":"HowTo"'
# 0
How to host a pub quiz contains a running order, which is an ordered set of steps a reader follows in sequence, and the HowTo node is built from that running order and nothing else. The rest of that page is advice, so the rest of that page is not in the markup.
Pub quiz round ideas is a list. You can do those in any order, or none of them. Marking it up as a HowTo would be describing the page as something it is not, and the fact that rich results sometimes reward you for doing it anyway is not a reason, it is a temptation.
The eight steps in that one node are generated from the same data structure that renders the visible running order, so a step cannot be in the markup and absent from the page.
The author is the company, because we do not have a person to name
The guides carry Article, and Article wants an author.
A named human author is the stronger signal, and we do not have one. Inventing "Sarah, Head of Quizzes" to satisfy a schema field is exactly the kind of thing structured data exists to stop, so the author is the same Organization node the whole site references by id, and the day a real person starts putting their name to these pages there is precisely one function to change.
The dates are the other half of that honesty. Both datePublished and dateModified are the date the page's content last changed:
curl -s https://pub-trivia.app/guides/how-to-host-a-pub-quiz | grep -o '"dateModified":"[^"]*"'
# "dateModified":"2026-09-14T00:00:00Z"
and the page prints the same date in plain English, as "Last reviewed 14 September 2026". These are evergreen pages revised in place, not dated posts. When a guide was first written is not a fact anybody needs; when it was last checked is, and it is the kind of claim that should be visible to the reader rather than only to a crawler.
That date comes from a field the author bumps by hand, not from the file's mtime. A reformat is not a revision, and a sitemap that announces fourteen guides changed this morning because a linter ran is a sitemap nobody believes twice.
Pages that are not articles do not get Article
curl -s https://pub-trivia.app/compare/paper-vs-app-quiz | grep -c '"@type":"Article"'
# 0
The layout every content page renders inside takes an article flag, defaulting to false. The guides set it. The comparison pages, the feature pages and the venue pages do not, because a product page marked up as editorial writing is a lie about the page, and it also drags the visible "last reviewed" line onto a pricing table where it would be pure noise.
One flag drives both the markup and that visible line, which is the only reason they agree.
Prices in the markup are the currency we bill in, always
The pricing page localises its visible price to the visitor's currency. The Offer nodes do not:
curl -s https://pub-trivia.app/features | grep -o '"priceCurrency":"[^"]*"'
# "priceCurrency":"GBP"
# "priceCurrency":"GBP"
A crawler cannot be told a per-visitor price. It fetches the page from one datacentre, once, and whatever it reads becomes the price in the index, so the machine-readable figure has to be the fixed one we actually charge. Deriving it per request would mean publishing a different price on every crawl and contradicting the visible small print at the same time.
Hubs list their children, and the list is the same list
A cluster index like /guides emits ItemList in the order the page shows its links, because both come from the same registry query:
curl -s https://pub-trivia.app/guides | grep -c '"@type":"ListItem"'
# 16
Fourteen guides plus two breadcrumb items. The breadcrumb trail is derived too, from the path, which is why a page cannot sit in a cluster and claim a trail through a different one.
There is one deliberate omission in that trail: the homepage emits no BreadcrumbList at all, because a one-item breadcrumb is not a trail. The builder returns null for it and the page skips the node rather than publishing an empty one.
The serialiser is one component, and that is a security decision too
Every node on the site goes out through one tiny component that does JSON.stringify into dangerouslySetInnerHTML. That is the documented way to do it in Next, and it was about to be typed out on sixty pages.
It is safe here for the reason it is always safe: everything passed into it comes from the content registry and the page's own module scope, never from a request. Keeping it in one place is what makes that sentence checkable by reading one file, rather than a property you have to re-verify every time somebody adds a page.
Check all of it yourself
Every claim above is a one-liner against the live site:
# A page whose markup and visible content are the same array
curl -s https://pub-trivia.app/faq | grep -c '"@type":"Question"'
# A procedure, and a list that is not one
curl -s https://pub-trivia.app/guides/how-to-host-a-pub-quiz | grep -o '"@type":"How[A-Za-z]*"' | sort | uniq -c
curl -s https://pub-trivia.app/guides/pub-quiz-round-ideas | grep -c '"@type":"HowTo"'
# Visible date, machine date
curl -s https://pub-trivia.app/guides/how-to-host-a-pub-quiz | grep -o '"dateModified":"[^"]*"'
Or paste any of those URLs into Google's Rich Results Test and read what it thinks the page is. The interesting part is not whether the markup validates, it is whether it describes the page you can see.
If you want to know what the product underneath the content does, pub-trivia.app/features is the honest version of that, and the first quiz session is free with no card.
Top comments (0)