Most sites emit structured data as a pile of disconnected assertions. The Article block says the publisher is "Acme". The Organization block, injected by a different plugin, also says "Acme". The BreadcrumbList sits in its own script tag. Nothing indicates that these describe the same entities, so a consumer parsing the page sees three or four unrelated things that happen to share a name string.
JSON-LD was designed to avoid exactly this. @id gives every node a stable identifier, and any parser doing standard JSON-LD processing merges nodes that share one. Used deliberately, it turns your markup from a set of isolated rich-result payloads into a single description of your site: one Organization, one WebSite, one WebPage per URL, each Article attached to the page it lives on.
Why disconnected blocks are a real problem, not a tidiness issue
A JSON-LD node without an @id is a blank node. It exists only inside that document, and it can never be referenced. Two blank nodes with identical properties are still two nodes.
That has practical consequences:
- Duplicate entities. Your homepage defines Acme once as
publisherinside Article and once as a top-level Organization. A consumer building an entity representation has to guess whether these are the same company. Usually it guesses correctly from the name and URL — but you've handed it a reconciliation problem you could have solved outright. - No place to hang authority signals.
sameAslinks to Wikidata, LinkedIn, and Crunchbase belong on one Organization node that everything else points at. Scattered across page-level copies, they're repeated noise. - Contradictions become invisible. When two nodes for the same entity carry different
logoorlegalNamevalues, nobody notices because nothing forces them into the same node.
Giving nodes identifiers makes conflicts collapse into one place, where they're either consistent or obviously broken.
What @id actually is, and how to choose values
@id is the node's identifier — an IRI. url is a property describing where the thing lives on the web. They are not the same field, and conflating them causes collisions: on a blog post, both the WebPage and the Article legitimately have url equal to the post URL, but they are different nodes and need different identifiers.
The convention that solves this is absolute URLs with fragment identifiers:
| Node | @id pattern | Defined in | Links out to |
|---|---|---|---|
| Organization | https://example.com/#organization | Sitewide layer | logo, sameAs |
| WebSite | https://example.com/#website | Sitewide layer | publisher → Organization |
| WebPage | {pageUrl}#webpage | Page layer | isPartOf → WebSite, breadcrumb |
| Article | {pageUrl}#article | Template layer | isPartOf/mainEntityOfPage → WebPage, author, publisher |
| Person (author) | https://example.com/authors/jane/#person | Author layer | sameAs, worksFor |
| BreadcrumbList | {pageUrl}#breadcrumb | Page layer | itemListElement |
| ImageObject | {pageUrl}#primaryimage | Page layer | — |
An @id does not have to resolve to a document. It has to be stable, globally unique, and identical everywhere the entity is mentioned. That last point is where implementations fail: trailing-slash inconsistency, http versus https, or www drift will silently split one entity into several.
What the assembled graph looks like
One script tag, one @graph, references instead of repetition:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://example.com/#organization",
"name": "Acme",
"url": "https://example.com/",
"logo": {"@type": "ImageObject", "@id": "https://example.com/#logo",
"url": "https://example.com/logo.png", "width": 600, "height": 60},
"sameAs": ["https://www.wikidata.org/wiki/Q000000",
"https://www.linkedin.com/company/acme/"]
},
{
"@type": "WebSite",
"@id": "https://example.com/#website",
"url": "https://example.com/",
"name": "Acme",
"publisher": {"@id": "https://example.com/#organization"}
},
{
"@type": "WebPage",
"@id": "https://example.com/blog/crawl-budget/#webpage",
"url": "https://example.com/blog/crawl-budget/",
"name": "How crawl budget works",
"isPartOf": {"@id": "https://example.com/#website"},
"breadcrumb": {"@id": "https://example.com/blog/crawl-budget/#breadcrumb"},
"primaryImageOfPage": {"@id": "https://example.com/blog/crawl-budget/#primaryimage"}
},
{
"@type": "Article",
"@id": "https://example.com/blog/crawl-budget/#article",
"headline": "How crawl budget works",
"datePublished": "2024-03-11T09:00:00-05:00",
"dateModified": "2025-01-08T14:20:00-05:00",
"mainEntityOfPage": {"@id": "https://example.com/blog/crawl-budget/#webpage"},
"author": {"@type": "Person", "@id": "https://example.com/authors/jane/#person",
"name": "Jane Reyes", "url": "https://example.com/authors/jane/"},
"publisher": {"@id": "https://example.com/#organization"},
"image": {"@id": "https://example.com/blog/crawl-budget/#primaryimage"}
}
]
}
Note the pattern for author: the node is defined and identified inline. That is deliberate, and the next section explains why.
Where Google follows references, and where you should not rely on it
Google documents JSON-LD as its recommended format and states that a page can carry multiple structured data blocks. Its parsers handle @graph — you can confirm this yourself with the Rich Results Test, which resolves nodes inside a graph and reports eligibility. In practice, @id references within the same page generally resolve.
What Google has not documented is a guarantee that every rich-result feature will chase an @id reference to find a required property. Feature documentation lists required properties for an item; it does not promise reference resolution as a way of satisfying them. And nothing suggests Google fetches a different URL to resolve an @id — an author node identified as /authors/jane/#person with no properties on the current page is, for eligibility purposes, empty.
So the decision rule:
- Inline the properties a rich result requires, in the node that needs them.
author.name,headline,image,offers,aggregateRating— define them where they're used. - Use
@idfor identity and de-duplication, not as a storage-saving mechanism. Referencing your Organization node by@idfrompublisheris safe because the Organization node is fully defined on the same page. - Repeat the sitewide nodes on every page. Same
@id, same values. Consumers merge; you lose nothing and you stop depending on cross-page resolution.
This costs a few hundred bytes per page and removes the main failure mode.
Assembling the graph in a real codebase
The architectural mistake is letting each component emit its own script tag. Instead, have templates contribute nodes to a collector that serializes once:
- Sitewide layer always contributes Organization and WebSite.
- Page layer contributes WebPage, BreadcrumbList, and the primary ImageObject, all keyed off the canonical URL.
- Content templates contribute Article, Product, FAQPage, and so on, referencing
{canonical}#webpage.
Derive every @id from the canonical URL, normalized once — same scheme, host, and trailing-slash policy your canonical tags use. If pagination, parameters, or locale variants produce different URLs, they produce different WebPage nodes, which is correct. For international sites, keep a single Organization @id on the root domain rather than one per locale, unless the locales genuinely represent separate legal entities.
Validating that the graph is actually connected
The Rich Results Test answers "am I eligible?" It does not answer "is my graph coherent?" Use the Schema.org Validator for the second question — it shows the full node structure, including types Google ignores.
The cheap automated check is a dangling-reference test: any @id that is referenced but never defined on the page. With the JSON-LD extracted to graph.json:
jq -r '
[..|objects|select(has("@id"))|.["@id"]] as $refs |
[.["@graph"][]|.["@id"]] as $defined |
($refs - $defined) | unique[]
' graph.json
Anything printed is either a legitimate external identifier (a Wikidata URL in sameAs) or a broken internal reference. Wire that into the same pre-deploy check that guards your other structured data.
What to do this week
- Fetch three representative URLs — homepage, a category, an article — and extract all JSON-LD. Count how many distinct nodes describe your organization. If it's more than one per page, you have a merge problem.
- Pick and document your
@idconventions, derived from canonical URLs. Write them down before implementing; inconsistency is the failure mode. - Consolidate emission into a single
@graphper page, contributed to by layered templates rather than independent plugins. - Keep required rich-result properties inline. Use references for identity, not for compression.
- Add the dangling-reference check to CI, and re-run the Schema.org Validator after any template change that touches URLs.