Schema and search appearance guide
Connect WordPress Schema Entities With Stable IDs
Give each entity one @id, state its facts once, and point other objects at it by that exact @id in every script, so your structured data names one organization.
On this page
Give each real thing your structured data describes (the business, a person, the website, a page, an article) one @id: a URL with a fragment, such as https://garden.example/#organization. Define that entity’s facts in one place. Everywhere else, including in a second script printed by another plugin, refer to it with an object that contains only {"@id": "..."}, using exactly the same value. Keep the identifiers exactly the same across pages and over time. That way every object on the site points at one organization instead of several near-copies with slightly different names.
This guide assumes you already know what your pages publish. If not, start with the structured data audit, which lists every object with its @id and source.
How @id and node references work
The JSON-LD 1.1 specification, W3C JSON-LD 1.1, checked September 27, 2026, says a node is identified using the @id keyword, and describes a node reference as “a node object containing only the @id property, which may represent a reference to a node object found elsewhere in the document.” Schema.org’s data model, checked the same day, notes that @id is JSON-LD’s built-in representation for URIs and URLs.
Google documents the same mechanism. Its structured data general guidelines, checked September 27, 2026, explain that it understands multiple items on a page whether they are nested or given as separate items, and advise using @id in both items to specify that one is about the other.
A minimal illustration: the organization is defined once, and the article refers to it.
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://garden.example/#organization",
"name": "Garden Example Co",
"url": "https://garden.example/"
},
{
"@type": "BlogPosting",
"@id": "https://garden.example/guides/pruning-tomatoes/#article",
"headline": "Pruning Tomatoes",
"publisher": { "@id": "https://garden.example/#organization" }
}
]
}
The @id is an identifier, not a page someone has to visit. Using your own URL plus a fragment keeps it unique to you and readable.
Map the entities and identifiers you already have
List every entity on a representative page with its current identifier. If you use WP Visibility 2.10.3, its graph already follows a fixed pattern. The publisher identifiers are documented in Set your site identity, and the full pattern is visible in the page source:
| Entity | @id pattern | Where it appears |
|---|---|---|
| The organization | https://your-site.com/#organization |
Every page, as publisher |
| Or the person the site represents | https://your-site.com/#person |
Every page, in person mode |
| The website | https://your-site.com/#website |
Every page |
| The current page | the page URL plus #webpage |
Every page |
| The breadcrumb trail | the page URL plus #breadcrumb |
Every page |
| The article | the post URL plus #article |
Blog posts |
| The post’s author | the author archive URL plus #author |
Blog posts and author archives |
WP Visibility prints no structured data on search results or the 404 page, so “every page” here means every other page. In person mode, if the chosen user ID does not match a user with a display name, the organization node prints instead.
Now add the objects printed by everything else: an events plugin, a theme, a page builder, custom code. For each, note whether it has an @id at all and what facts it repeats. One thing to look for is a second script with its own Organization object, no @id, and a slightly different name.
One point to check in person mode. In WP Visibility 2.10.3 the person the site represents (#person) and the same person as a post author (the archive URL plus #author) are separate nodes with separate identifiers. On a personal site with one author, you may want them treated as one entity. That is a developer change through the plugin’s documented schema filters, and it deserves the same review and testing as any other code.
Connect a second script without duplicating facts
Suppose an events plugin prints this on a class page (illustrative):
{
"@context": "https://schema.org",
"@type": "Event",
"name": "Spring pruning class",
"startDate": "2026-04-18T10:00:00-04:00",
"organizer": { "@type": "Organization", "name": "Garden Co", "url": "http://garden.example" }
}
The organizer repeats facts, and they disagree with the main graph: a different name, http instead of https, and no trailing slash. Replace the embedded object with a reference to the entity you already define:
{
"@context": "https://schema.org",
"@type": "Event",
"name": "Spring pruning class",
"startDate": "2026-04-18T10:00:00-04:00",
"organizer": { "@id": "https://garden.example/#organization" }
}
The JSON-LD 1.1 specification says that when a page’s JSON-LD is extracted as RDF, unless one script is targeted, all JSON-LD script elements on a page “MUST be processed and merged into a single dataset”, so a reference in one script can name a node defined in another. Google’s guidelines do not describe how Google joins identifiers across scripts, so treat the exact match as the clearest statement you can make, not as a promise about any feature. It removes the contradictory facts either way. Where the plugin offers a setting for the organizer, use it; where it offers a filter, a developer can change the value there. Do not rewrite the page HTML after it is generated to swap values; that can break on the next plugin update.
Consistency means exact matches. These are four different identifiers, even though a person would read them as the same:
https://garden.example/#organizationhttp://garden.example/#organizationhttps://www.garden.example/#organizationhttps://garden.example/#org
Keep identifiers stable over time
An identifier is only useful if it keeps pointing at the same thing:
- Base it on the canonical address. Use the site’s preferred protocol and host. Moving from
httptohttpsor changing domains changes every identifier built on the URL; after a move, check that every component switched together. - Do not use values that change. A version number, a modified date, or a post ID that can change when content is migrated or re-imported makes the identifier drift.
- One identifier per entity. If two components invent their own identifiers for the organization, pick one and point the other at it.
- Define facts once. Name, logo,
sameAsprofiles and URL belong on the defining node. References carry only the@id.
Validate the graph after changes
Syntax and vocabulary come first: run the page through the Schema Markup Validator. Then check the references. This script lists every @id that is defined on a page, flags identifiers defined more than once, and lists references with no matching node on the same page:
# check_ids.py: find @id references with no matching node on the same page.
# Usage: python check_ids.py https://garden.example/some-post/
import json, re, sys, urllib.request
SCRIPT = re.compile(r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>', re.S | re.I)
defined, referenced = {}, set()
def walk(value, script):
if isinstance(value, list):
for item in value:
walk(item, script)
elif isinstance(value, dict):
node_id = value.get("@id")
if node_id and set(value) - {"@id"}:
defined.setdefault(node_id, []).append((script, value.get("@type")))
elif node_id:
referenced.add(node_id)
for key, item in value.items():
if key != "@id":
walk(item, script)
request = urllib.request.Request(sys.argv[1], headers={"User-Agent": "schema-id-check"})
html = urllib.request.urlopen(request, timeout=15).read().decode("utf-8", "replace")
for n, block in enumerate(SCRIPT.findall(html), 1):
try:
walk(json.loads(block), n)
except json.JSONDecodeError:
print(f"script {n}: invalid JSON, fix it first")
for node_id, places in sorted(defined.items()):
note = " defined more than once, compare the facts" if len(places) > 1 else ""
print(f"defined {node_id} {places}{note}")
for node_id in sorted(referenced - set(defined)):
print(f"no match {node_id}")
Read the results this way:
| Result | Meaning | Action |
|---|---|---|
no match for an identifier you expected |
A typo or a protocol or host mismatch | Fix the reference to the exact defined value |
no match for an entity defined on another page |
The reference points off the page | Decide whether that is intended; defining it on the page is clearer |
defined more than once |
Two objects claim the same identity | Compare their facts; keep one definition |
Then run the page through the Rich Results Test if a Google feature depends on the markup. That is a separate question from whether the graph is coherent. A connected graph describes your business consistently. It does not by itself produce a rich result or a knowledge panel. Google’s guidelines say Google does not guarantee that structured data will show up in search results, even when the markup is correct, and the one effect of linking they describe is narrow: if a recipe and a video are not linked, Google Search may not know it can show the video as a Recipe rich result.
Entity graph checklist
- Every entity has one identifier based on the canonical address.
- Facts are defined once; other objects use
{"@id": ...}references. - Second scripts from plugins or the theme point at the same identifiers.
- No
http,wwwor fragment variants of the same entity. check_ids.pyshows no unexpectedno matchand no conflicting duplicates.- Validated after every plugin, theme or domain change.
