I was auditing the structured data on a lodging site with twelve locations, checking which pages carried pricing markup, when I noticed something odd in the node listing. Four pages each declared two WebPage nodes. Not two blocks of JSON. Two nodes of the same type, on one page, each claiming to describe it.
One of them was correct. The other one said it was the About page.
Here is what was actually sitting in the head of a page about weekly rates, reformatted but otherwise untouched:
{
"@context": "https://schema.org",
"@type": "WebPage",
"dateModified": "2026-08-30",
"datePublished": "2026-04-18",
"@id": "https://exampleinns.com/about.html#webpage",
"url": "https://exampleinns.com/about.html",
"name": "About Example Inns",
"isPartOf": {
"@type": "WebSite",
"@id": "https://exampleinns.com/#website"
}
}
Every field in that node is accurate. It is a faithful description of the About page. The problem is that it was not on the About page. It was on four other pages, in its own <script type="application/ld+json"> block, sitting a few hundred bytes below each page's own perfectly correct WebPage node.
I have started calling this a foreign node: a structured data node that is internally valid, externally accurate, and attached to the wrong document.
Why nothing in the toolchain caught it
This is the part worth sitting with, because the failure is not in any one tool. It is in the space between them.
The JSON parses. It is well formed. A syntax check passes.
Each node is valid schema.org. WebPage with @id, url, name, datePublished, dateModified and isPartOf is a textbook node. Run it through a schema validator on its own and you get a clean result.
The Rich Results Test is happy. It is looking for eligibility for specific rich result types. Two WebPage nodes where one names a different URL does not make anything ineligible, so nothing is reported.
The page renders identically. JSON-LD is invisible. There is no visual regression, no layout shift, no console error. Nothing a human reviewing the page in a browser would ever see.
Search Console does not report it either. Its structured data reports are organised by rich result type. A plain WebPage node is not a rich result type, so there is no report for it to appear under.
So you have a defect that survives syntax checking, schema validation, rich results testing, visual review and Search Console. That is not a gap in one product. That is a category of mistake that current tooling is not shaped to see, because every tool in the chain validates nodes and none of them validates the relationship between a node and the file it lives in.
The assertion nobody makes is the simple one: the WebPage node on this page should describe this page.
How it happens
The mechanism is mundane, which is exactly why it spreads.
At some point someone wanted datePublished and dateModified on a set of pages. The main JSON-LD graph on those pages did not carry dates. Rather than restructure the existing graph, the quick fix is to append a second, standalone block with a small WebPage node that carries them. That is a completely reasonable thing to do. JSON-LD allows multiple blocks on a page and consumers merge them.
The block gets written once, on one page, correctly. Then it gets applied to the others.
And that is where it goes wrong, because the block contains three fields that are page identity, not page metadata: @id, url and name. The dates are the thing you wanted to copy. The identity is the thing you had to change. If whoever applied it replaced the dates and forgot the identity, or applied the block wholesale from a template, the source page's identity rides along.
Here is the detail that made the mechanism legible in this case. Five pages were built in the same batch, on the same day, with the same structure. Four of them carried the foreign node. The fifth carried a correct one, with its own @id.
That asymmetry is the fingerprint. A bug in a generator produces a uniform result. Four wrong and one right is what a human copy looks like, or a template that got fixed partway through a list and never backfilled. When you find a defect that hits most but not all of a batch, the odd one out usually tells you how the batch was made.
The other tell was in the dates themselves. The foreign node carried 2026-04-18 and 2026-08-30. The pages it was sitting on were all created on 2026-09-02 and said so in their own nodes. The block was not just naming another page. It was importing that page's history.
What it actually costs
I want to be careful here, because it is easy to oversell schema defects and I would rather be useful than dramatic.
Google does not publish what it does when a page declares two WebPage nodes with different @id values. It may pick one. It may merge them. It may ignore both. I do not know, and anyone who tells you they know precisely is guessing.
What I can say is what you have handed it, and that is bad on its own terms:
A contradictory identity claim. @id in JSON-LD is not decoration. It is the identifier a consumer uses to decide whether two nodes describe the same thing. You have put two different identifiers for two different documents on one document.
A false modified date in circulation. One node says the page changed on the day it was actually published. The other says it changed on a date belonging to a different page, months earlier. Freshness signals are one of the few things structured data reliably contributes.
A node that will merge with the real About page. Because @id is a global identifier, a consumer building a graph across your site now has the About page's node asserted from five different URLs. If any of those copies drifts from the real one, the About page's own description becomes ambiguous too. The defect does not stay on the page it landed on.
Wasted crawl attention on pages you care about most. In this case all four affected pages were commercial pages that had just been submitted for indexing. The pages you hand-pick for a manual submission are precisely the ones you least want carrying a confused identity.
None of that is a penalty. It is noise you are volunteering, on the pages where you can least afford it.
Detecting it
The check is short, because the rule is short. Parse every JSON-LD block, collect the WebPage nodes, and assert that there is one and that it names the file it is in.
import fs from 'node:fs';
import path from 'node:path';
const SITE = 'https://exampleinns.com';
function webPageNodes(html) {
const out = [];
const re = /<script[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(html))) {
let parsed;
try { parsed = JSON.parse(m[1]); } catch { continue; }
const nodes = Array.isArray(parsed) ? parsed : (parsed['@graph'] || [parsed]);
for (const n of nodes) if (n && n['@type'] === 'WebPage') out.push(n);
}
return out;
}
function expectedUrl(file, root) {
const rel = path.relative(root, file).split(path.sep).join('/');
return `${SITE}/${rel}`;
}
export function auditFile(file, root) {
const nodes = webPageNodes(fs.readFileSync(file, 'utf8'));
const want = expectedUrl(file, root);
const problems = [];
if (nodes.length === 0) problems.push('no WebPage node');
if (nodes.length > 1) problems.push(`${nodes.length} WebPage nodes, expected 1`);
for (const n of nodes) {
const id = String(n['@id'] || '');
const url = String(n.url || '');
// Strip the fragment before comparing. #webpage is a naming convention,
// not part of the document identity.
if (id && id.split('#')[0] !== want) problems.push(`@id names ${id}`);
if (url && url !== want) problems.push(`url names ${url}`);
}
return problems;
}
Two things about that check are deliberate.
It compares against the file path rather than against the page's own canonical tag. If you validate the node against the canonical, a page whose canonical is also wrong passes, and those two mistakes travel together more often than you would like. The filesystem is the one source that cannot have been copied from somewhere else.
It splits the fragment off before comparing. #webpage, #main, #page are all common suffixes and none of them change which document is being identified. Comparing the raw strings produces false positives that make people turn the check off, which is worse than not having it.
Run that over a site and you get an answer in a second or two. On the site I was looking at, four files came back, and every other page was clean.
The same shape catches the neighbouring version of this bug, where a page carries a BreadcrumbList whose final item points at a different URL than the page it is on. Same cause, same invisibility, same one line fix.
Fixing it without breaking something else
The removal is where people create a second problem, so it is worth being deliberate.
The temptation is to find the string about.html#webpage and delete the surrounding block. Do not do that. String matching a block in order to edit a node inside it is how you delete real content that happened to share a container. If the foreign @id had been one node inside a @graph array alongside the page's actual data, a block level delete takes the page's own markup with it.
The safe rule is narrow: delete a block only when that block's entire content is the single foreign node, and report anything else for a human to look at.
const nodes = Array.isArray(parsed) ? parsed : (parsed['@graph'] || [parsed]);
const solelyForeign =
nodes.length === 1 && String(nodes[0]['@id'] || '') === FOREIGN_ID;
if (!solelyForeign) {
if (JSON.stringify(parsed).includes(FOREIGN_ID)) {
report.push(`MANUAL: ${file} mixes the foreign id into a block with other nodes`);
}
continue;
}
Also, exclude the page that legitimately owns the node. The About page is supposed to have an About page node. A cleanup that removes it everywhere including where it belongs has traded one silent defect for another, and the second one is harder to notice because the page now has no WebPage node at all rather than two.
In this case the fix removed four blocks and left the two pages that legitimately own that node untouched, the English one and its Spanish twin.
The pattern underneath
I keep meeting this same shape in different costumes.
A fact gets stated in one place, correctly. Then a second copy of that fact is created somewhere else for a good local reason. The two copies agree on the day they are written. Nothing in the build asserts that they still agree. Time passes, one side changes, and the disagreement is invisible because both halves are individually valid.
A canonical tag that names a different URL than the sitemap. A dateModified in the markup that no longer matches the date rendered on the page. A generator that decides which pages are indexable using a rule the renderer also decides, separately. A page identity written into a template that got copied before the identity was parameterised.
Every one of those passes validation on both sides. What is missing in each case is not a better validator. It is a cheap assertion that the two places still say the same thing, running in the build where a disagreement fails loudly instead of shipping quietly.
The foreign WebPage node is a good one to start with, because the assertion is a single comparison and the file path gives you ground truth for free. If you have never checked, check. It takes a second, and in my experience the sites that have this have it on more than one page, because whatever copied it once copied it several times.
If you want the wider map of how these structured data and search visibility pieces fit together without paying an agency retainer to assemble it, that is what I wrote The $20 Dollar Agency for.
Related reading
Your JSON-LD validates but Google ignores it because the graph is broken: the companion failure, where nodes are individually valid but the graph connecting them is not.
Circular Canonicals Tank Both Pages: the same class of identity confusion, expressed through canonical tags instead of @id.
Date-only YYYY-MM-DD in JSON-LD trips Search Console: what happens when the dates in these nodes are shaped wrong rather than copied wrong.
Why a Multi-Location Schema Audit Exists: templating identity across many near identical location pages, which is where foreign nodes breed.
Why Schema Completeness Exists: the broader question of which nodes a page should carry before you worry about whether they are correct.
Fact-check notes and sources
@id is a node identifier, not a label: the JSON-LD 1.1 specification defines @id as the unique identifier for a node object, which is what makes two nodes sharing an @id merge into one. W3C JSON-LD 1.1, node identifiers
Multiple JSON-LD blocks on one page are legal: Google states it reads structured data from multiple blocks and that markup can be split across them, which is why appending a second block is a normal thing to do and why the mistake is easy to make. Google Search Central, structured data general guidelines
WebPage is not a rich result type: Google's structured data reports in Search Console are organised by supported feature, and the supported feature list does not include a plain WebPage, which is why nothing surfaces there. Google Search Central, structured data markup that Google Search supports
WebPage and its properties: url, name, datePublished, dateModified and isPartOf are all standard properties, so a foreign node is fully valid in isolation. schema.org WebPage
Rich Results Test scope: Google documents the tool as testing eligibility for rich results, not as a general correctness checker for all markup on the page. Google Rich Results Test
This post is informational, not legal or SEO consulting advice. The site described has been anonymised and the example domain is a placeholder. Mentions of Google, Search Console and schema.org are nominative fair use and no affiliation is implied.