Why does a second JSON-LD block create a second version of your entity?
Structured data for AI search is usually treated as a per-page annotation job, and that framing is what produces the failures. The unit that matters is not the block, it is the graph: one @graph per page whose nodes are joined by stable @id anchors, so that whichever URL a crawler lands on, your person, your site and your work resolve to the same identifiers. Get that shape wrong and no amount of correct field-level markup saves you, because the resolver is comparing your pages against each other.
Start with what a parser actually does. A JSON-LD block is a list of nodes. A node with an @id is a named entity that can be referenced from anywhere, including from a different document. A node without one is a blank node: its identity is local to that document, so it cannot be matched by identifier on the next page. When two blocks describe the same person without a shared @id, a resolver has only property similarity to work with, and that comparison is exactly the one that fails when the two blocks disagree about the job title.
That disagreement is the normal case, not the pathological one, because the two blocks usually come from two different owners. My own observation from auditing a marketing site whose head contained three plugins — an SEO plugin emitting Person and Organization, a local-business plugin emitting LocalBusiness, and a review widget emitting Product with an AggregateRating — was that the two person-like nodes carried Founder and Principal Consultant respectively, and identical sameAs values for LinkedIn only. Nothing errored. The Rich Results test validated both blocks, because both blocks were individually valid. Validity is a per-block property; identity is not.
One
@graphper page, with every node referenced by a stable@id, is what makes a retrieval system meet the same entities on every URL; disconnected blocks make it meet several competing versions and choose between them without you.
What does one graph per page look like in a Next.js route?
The implementation is smaller than the discussion around it. The whole design is one frozen map of identifiers, one function per base node, and one exported wrapper that a route calls. Page-specific nodes are appended; they are never merged with the base nodes and never allowed to restate them.
import { identity } from '@/content/config/identity';
/** The canonical origin, no trailing slash. Every identifier is built from it. */
const SITE_URL = identity.url;
/** One frozen map. Nodes reference these; they never restate the entity. */
export const entityIds = {
person: `${SITE_URL}/about#person`,
website: `${SITE_URL}/#website`,
organization: `${SITE_URL}/#organization`,
} as const;
type Node = Record<string, unknown>;
const person = (): Node => ({
'@type': 'Person',
'@id': entityIds.person,
name: 'Will Chan',
jobTitle: 'Design Engineer & AI Systems Architect',
url: `${SITE_URL}/about`,
knowsAbout: ['Model Context Protocol', 'AI systems architecture', 'Next.js'],
});
const website = (): Node => ({
'@type': 'WebSite',
'@id': entityIds.website,
url: SITE_URL,
publisher: { '@id': entityIds.person },
});
/** The only function a route calls. Base nodes are never optional. */
export function graph(pageNodes: Node[]): { '@context': 'https://schema.org'; '@graph': Node[] } {
return {
'@context': 'https://schema.org',
'@graph': [person(), website(), ...pageNodes],
};
}
Three properties of that shape carry the weight. The base nodes are unconditional, so a crawl that starts at /lab and never reaches /about still resolves the author of what it found. The identifiers are built from one constant and absolute from the start, so a relative @id is not reachable from the code. And because page nodes are appended rather than written into the base, a route author has no place to put a second copy of the person.
Where do competing versions actually come from?
A taxonomy is more useful here than a rule, because each row has a different repair. Read every row against one question: is there a stable identifier behind the node? Where there is not, the resolver is guessing, and the guess is made on whatever properties happen to match.
| Failure | What the crawler reads | What it resolves to |
|---|---|---|
| Two plugins, two blocks | Person and Organization with no @id, different jobTitle | Two entities: a person and an unattributed brand |
| Same entity restated per route | One @id, page-specific description or jobTitle | One entity with contradictory facts; the value depends on crawl order |
author: { "name": "Will Chan" } inline | A fresh blank-node person per article | One author node per URL, none of them the entity on /about |
@id rebuilt from the current slug | A different identifier per rename | One work split in two, with citations split alongside it |
sameAs pointing at a 404 or an unrelated profile | A failed bridge to an external identity | No merge, and no attribution outside your own domain |
| Markup contradicting visible text | Two disagreeing dates or headlines on one page | Nothing resolves; the page loses trust, not just the field |
The third row is the most common and the least visible. This article is one of 47 English posts on this site. If each of those posts wrote its author as a name string, a resolver would face 47 separate decisions about whether that string is the person described on /about, and it would make them by string equality at best. With a shared @id, it faces one.
How do you keep the same node on every URL?
Take the graph emitted for this page, abridged to the fields that carry the argument. The person appears once, in full, and the article node contributes references instead of facts about the author.
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": "https://willchan.me/about#person",
"name": "Will Chan",
"jobTitle": "Design Engineer & AI Systems Architect"
},
{
"@type": "WebSite",
"@id": "https://willchan.me/#website",
"url": "https://willchan.me/",
"publisher": { "@id": "https://willchan.me/about#person" }
},
{
"@type": ["BlogPosting", "TechArticle"],
"@id": "https://willchan.me/blog/structured-data-that-ai-engines-read#article",
"headline": "Structured Data for AI Search: The Entity Graph Matters More Than the Markup",
"datePublished": "2026-03-09",
"inLanguage": "en",
"wordCount": 1842,
"author": { "@id": "https://willchan.me/about#person" },
"publisher": { "@id": "https://willchan.me/about#person" },
"isPartOf": { "@id": "https://willchan.me/#website" }
}
]
}
The practical consequence is that a fact lives in one place. Changing the job title is a one-line edit to person(), and every URL reflects it on the next build, including the pages nobody remembered had ever mentioned it. There is no second copy to drift, and no reconciliation step where a stale copy wins because the page carrying it was crawled more recently.
Pages that genuinely need more about the same entity should extend the base node rather than write a new one. On /about I spread the base function into a new object and add only what applies there, mapping the timeline into an array of OccupationalRole nodes. The identifier stays identical and every added property is additive, so the extra facts strengthen one entity instead of proposing a second.
Rendering is the last place this breaks, and it breaks quietly. JSON.stringify output is not safe to interpolate into a <script> element: a headline containing </script> terminates the block early, and the HTML parser then treats the remainder as markup. The fix is to escape the three HTML-significant characters into JSON string escapes, which every JSON-LD consumer parses back to the original characters.
import type { JsonLdGraph } from '@/lib/schema';
export function JsonLd({ data }: { data: JsonLdGraph }) {
const json = JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
return (
<script
type="application/ld+json"
data-geo="jsonld"
dangerouslySetInnerHTML={{ __html: json }}
/>
);
}
I render it in the body rather than the head. <script type="application/ld+json"> is flow content, every consumer reads it from anywhere in the document, and moving it into the head fights streaming for no benefit. One component, one call per route, one block in the response.
How do you verify consistency instead of trusting it?
The invariant is one sentence: every URL in the sitemap must resolve the person to the same identity. Comparing whole nodes does not test that, because a page is allowed to add to a node — /about here emits the person twice under the same @id, once from the base graph and once with hasOccupation attached, and a JSON-LD processor merges the two definitions into one subject. So the check compares the fields that must never vary, collapses identical definitions within a page, and asserts that the whole site reduces to a single value. It runs from outside the app, with no build hooks and no test framework, because the check has to survive contact with a codebase other people edit.
# One line per URL: the identity core of its Person node, or MISSING.
curl -sS https://willchan.me/sitemap.xml | grep -o '<loc>[^<]*' | cut -c 6- \
| while read -r url; do
core=$(curl -sS "$url" \
| grep -o '<script type="application/ld+json"[^>]*>[^<]*</script>' \
| sed 's/<script[^>]*>//; s|</script>||' \
| jq -cS '."@graph"[] | select(."@id" == "https://willchan.me/about#person")
| { "@type", "@id", name, alternateName, url, jobTitle }' \
| sort -u | tr '\n' ' ')
printf '%s\t%s\n' "${core:-MISSING}" "$url"
done | tee /tmp/person-identity.tsv
grep -c '^MISSING' /tmp/person-identity.tsv # must print 0
cut -f1 /tmp/person-identity.tsv | sort -u | wc -l # must print exactly 1
Two counts, two different failures. MISSING on any line means that URL emitted no person node at all, which happens the moment a route stops calling the wrapper. A distinct count above one means some URL disagrees about the entity — including the case where a single page carries two conflicting versions of it, since those survive sort -u together on one line. jq -cS sorts keys, so the comparison is on content rather than property order, and the [^<]* in the extraction pattern is safe precisely because the escaping above removes every < from the serialised block.
Fifteen lines of shell, asserting something a human cannot hold in their head across 50 URLs, which is the only reason it is worth writing.
When is one graph per page the wrong approach?
When a plugin owns your document head and you cannot turn it off. Adding a second, better graph next to a worse one produces exactly the failure this article is about. Either you control emission or you do not; the work is to make one system the owner of the head, and if that is genuinely impossible, the honest position is that per-page graph identity is out of reach on that stack.
When the entity has no corroboration outside your domain. Consistency is necessary and nowhere near sufficient. External identity is what lets a resolver merge your node with anything it already believes, and no amount of graph hygiene substitutes for it. The person node this site emits carries no sameAs list at all, so every fact in it is a claim only my own domain repeats — a real gap in the entity work here, and the fix is an identity list rather than more markup.
When the catalog is large enough that the graph becomes payload. If a product node serialises to roughly 600 bytes — a name, a description, two offers, three identifiers — then 500 of them inline is about 300 KB of script on one listing page, before compression. The shape that scales is full nodes on the detail URL and reference-only @id entries in lists, at roughly 90 bytes each, so the index carries about 45 KB and each fact still has one authoritative home. That is arithmetic on stated per-node sizes rather than a measurement of a particular site.
When the site is one page. A single document is trivially consistent, and the afternoon belongs in the prose.
When markup is being used as a substitute for content. A perfect graph around a paragraph nobody would cite changes nothing about retrievability. And when fields would have to be invented to fill the shape: an aggregateRating you cannot verify, or a priceRange you decline to quote, is a false claim with a manual-action risk attached, and omission is the correct value.
If the question is where this work belongs inside a paid engagement rather than on a personal site, what an AI consultant should deliver in week one treats identity, URL and structured-data decisions as part of the written brief. The reason is sequencing rather than ceremony: an @id and a slug are cheap to choose once before publication and expensive to change after citations exist.
What should you actually do first?
Freeze the identifiers before you write the graph. One module exporting the @id values for the person, the site and the organisation, plus a graph() wrapper that every route calls, is an hour of work and removes the entire class of failure where two pages disagree. Then write the check above and wire it into the pre-deploy step, because a consistency invariant that is only inspected by hand is a consistency invariant that is already broken somewhere you have not looked.
Expect the first run to report pages with no graph at all rather than divergent graphs. That is the better problem: a route that never called the wrapper has an obvious fix and no cached history to unwind, while two versions of the same person that have been published for a year have both been read. Markup tells an engine what a page contains; the graph tells it what your site is. A site that answers that question differently on every URL will be summarised, and the summary will not name it.
Keep reading
- hreflang on a Bilingual Site Is Three Invariants: One URL Per Document, Absolute Values, Real Alternates2026-02-078 minEngineering
- GEO for Engineers: Making Your Site Legible to Language Models2026-03-216 minEngineering
- Static by Default: Next.js App Router Rendering Strategies2026-03-159 minEngineering
- Automated SEO Regression Testing: The Failures Are Silent, So the Invariants Have to Be Asserted2026-01-149 minEngineering
- Core Web Vitals Budgets Are Only Real When CI Fails the Build2026-03-128 minEngineering