Structured Data in Next.js, Generated From One Graph
Most JSON-LD ships as four disconnected blobs that each re-declare the organisation. One connected graph with stable @id references is less code, and the version a crawler can resolve.
Structured data is the part of technical SEO with the widest gap between what
teams ship and what the specification rewards. The usual implementation is a
component per page type, each emitting its own <script type="application/ld+json">,
each re-declaring the organisation, the site and the logo.
It validates. It also tells a crawler that your site contains four unrelated organisations that happen to share a name.
One graph, not four blobs
The @graph key exists for this. It holds a list of nodes, each with a stable
@id, and nodes refer to each other by that @id instead of repeating each
other's contents.
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "Organization", "@id": "https://example.com/#org", "name": "Example" },
{ "@type": "WebSite", "@id": "https://example.com/#site", "publisher": { "@id": "https://example.com/#org" } },
{ "@type": "WebPage", "@id": "https://example.com/blog/post#page", "isPartOf": { "@id": "https://example.com/#site" } },
{ "@type": "Article", "@id": "https://example.com/blog/post#article", "mainEntityOfPage": { "@id": "https://example.com/blog/post#page" } }
]
}Four nodes, one organisation, and every relationship stated once. The @id
values are the whole mechanism: they are what lets a crawler merge the node it
sees here with the same node on every other page.
Two rules make them work:
- Absolute URLs, always.
#orgalone is not an identifier, it is a fragment that means something different on every page. - Stable forever. An
@idthat changes between deploys - because it was built from a slug that got edited, or from a database row id - discards whatever the crawler had accumulated against it.
Generating it instead of writing it
The reason hand-written JSON-LD drifts is that nothing forces it to agree with
the page. The title changes in the metadata export and the headline in the
structured data keeps the old one, because they are two strings in two files.
The fix is one typed helper, called from the place that already knows the answer:
// lib/jsonld.ts
export function articleNode({ url, title, description, published, modified }: ArticleInput) {
return {
'@type': 'Article',
'@id': `${url}#article`,
headline: title,
description,
datePublished: published,
dateModified: modified ?? published,
mainEntityOfPage: { '@id': `${url}#page` },
publisher: { '@id': `${SITE}#org` },
};
}// app/blog/[slug]/page.tsx
<JsonLd data={graph(
webPageNode({ url, title: doc.title, description: doc.description }),
articleNode({ url, title: doc.title, description: doc.description,
published: doc.date, modified: doc.updated }),
breadcrumbNode(trail),
)} />Now the structured data cannot disagree with the page, because it is reading the same variables the page renders. That is the same principle as canonicals and sitemaps that cannot drift: one source of truth, generated at both ends.
Where it belongs in the response
Put it in the server-rendered HTML. A <script> injected by a client
component after hydration is markup a crawler may or may not execute, and
there is no reason to take the bet - the data is known on the server.
export function JsonLd({ data }: { data: object }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}dangerouslySetInnerHTML is correct here and not a shortcut: React escapes
text children, which corrupts the JSON. What you must not do is interpolate
user-controlled text without escaping < - a review comment containing
</script> will otherwise close the tag and execute what follows.
The types worth shipping, and the ones that are theatre
Structured data earns its place when it maps to a rich result or resolves an entity. Beyond that it is markup nobody reads.
Worth shipping:
OrganizationandWebSite, once, referenced everywhere.BreadcrumbList, which shows up directly in results.Articleon posts,Productwith realOfferdata on products.FAQPage, but only where the questions are visibly on the page. Marking up questions that are not rendered is a guidelines violation, not a clever trick.LocalBusinessor a subtype where there is a real address. Note that the subtypes requireaddress-ProfessionalServicewithout one is invalid, and it is a common way to ship a broken graph while believing it is richer.
Not worth much:
Reviewyou wrote about yourself. Self-serving review markup has been ineligible for rich results for years.HowToandRecipeon pages that are neither.speakable,SiteNavigationElement, and the rest of the long tail that no surface consumes.
Checking it
Two tools, and they answer different questions. The Rich Results Test tells
you whether a page is eligible for a specific result type. The Schema Markup
Validator tells you whether your graph is well formed - it is the one that
catches a dangling @id reference, which the first tool will happily ignore.
Run both, on the rendered HTML of the deployed page rather than on a pasted snippet. The snippet is what you wrote; the rendered HTML is what you shipped, and on sites with a caching layer in front they are not reliably the same thing.
