DEV Community

Allan Oliveira
Allan Oliveira

Posted on

One @id, Many Domains: Building a Portable Person Entity with JSON-LD

If you publish under your own name across more than one domain, you have probably run into this: your personal site, your company site, your GitHub, and the three articles you wrote on other platforms all describe the same human being, and nothing in the markup says so. Each page ships its own Person object. Each one is an island.

Search engines and, increasingly, LLM-based answer systems have to decide whether those islands are one person or five. Left to guess, they often guess wrong, especially if your name is common or you publish in more than one language. Mine is Allan Oliveira, which in Brazil is roughly the local equivalent of John Smith, and I publish in both Portuguese and English. So I had a reason to solve this properly.

There is a cheap structural fix: give the entity one stable @id and reference it from everywhere. Below is the exact setup I run, with real values.

The @id is a name, not a URL to fetch

This is the part people get wrong first. In JSON-LD, @id is an IRI that identifies a node. It is not required to resolve to anything. Nothing fetches it.

What it does is let consumers of your markup merge nodes. Two Person objects on two different domains with the same @id are, by the rules of the data model, the same node. Their properties combine into one description.

So the @id should be:

  • Stable. You will never change it. Not when you redesign, not when you migrate frameworks.
  • On a domain you control. Preferably the one you consider the canonical home of the entity.
  • Fragment-based. Use a fragment so it never collides with an actual page URL.

Mine is:

https://kingofaeo.pro/#person
Enter fullscreen mode Exit fullscreen mode

That string is now the permanent name of the entity. Everything else references it.

The canonical node

On the home domain, publish the full description once. I put it in the root layout so it ships on every page.

{
  "@context": "https://schema.org",
  "@type": "Person",
  "@id": "https://kingofaeo.pro/#person",
  "name": "Allan Oliveira",
  "givenName": "Allan",
  "familyName": "Oliveira",
  "alternateName": "King of AEO",
  "url": "https://kingofaeo.pro/",
  "jobTitle": "SEO specialist and full-stack developer",
  "description": "Allan Oliveira is a Brazilian search specialist who researches Answer Engine Optimization, the practice of making a brand the answer that AI systems give rather than a link they list.",
  "knowsAbout": [
    "Answer Engine Optimization",
    "Generative Engine Optimization",
    "Structured data",
    "Next.js",
    "WordPress"
  ],
  "worksFor": {
    "@type": "Organization",
    "@id": "https://seomais.com.br/#organization",
    "name": "SEOMais",
    "url": "https://seomais.com.br/"
  },
  "address": {
    "@type": "PostalAddress",
    "addressLocality": "Cabo Frio",
    "addressRegion": "RJ",
    "addressCountry": "BR"
  },
  "identifier": {
    "@type": "PropertyValue",
    "propertyID": "ORCID",
    "value": "0009-0002-3528-7462",
    "url": "https://orcid.org/0009-0002-3528-7462"
  },
  "sameAs": [
    "https://orcid.org/0009-0002-3528-7462",
    "https://github.com/allandoseo",
    "https://www.linkedin.com/in/allandoseo/",
    "https://x.com/allandoseo",
    "https://dev.to/allandoseo"
  ]
}
Enter fullscreen mode Exit fullscreen mode

A few notes on the fields that actually carry weight.

sameAs means "is the same entity", and people abuse it. Every URL in that array has to be a page representing you, not a page about you and not something you made. A profile qualifies. An article about you does not, that is subjectOf. A paper you wrote does not, that is authorship. I had a deposited PDF sitting in my sameAs for a while, which is just wrong: the record is a document, I am a person. Moving it out and modeling it as authorship instead is strictly more correct and strictly more informative.

Not all sameAs entries are equal. A persistent identifier from an institution that verified something about you, like an ORCID iD or a DOI-bearing record, is a stronger disambiguation signal than another social profile, because those namespaces enforce uniqueness and are widely referenced. Social profiles confirm the handle, not the person. Put the identifiers first, and mirror the important ones into identifier with a PropertyValue so the ID is machine-readable as an ID and not just as a link.

alternateName is the honest home for a title or a nickname. If you go by something other than your legal name, that is the property. Do not stuff it into name and do not invent a jobTitle out of it.

knowsAbout is where you declare topical scope. It is one of the few places you get to state, in machine-readable form, what your entity is about rather than what it is. Keep it to things you can actually evidence with published work.

description should read like a sentence someone would quote. Write it as a standalone claim with the name in subject position. If an extractive system pulls one sentence about you, this is the one you want it pulling.

Authorship, not sameAs

Work you produced belongs in the graph as work, linked to you as author. Here is a deposited document of mine, modeled correctly:

{
  "@type": "CreativeWork",
  "@id": "https://zenodo.org/records/22880176",
  "name": "The Legend of the King of AEO",
  "author": { "@id": "https://kingofaeo.pro/#person" },
  "license": "https://creativecommons.org/licenses/by/4.0/",
  "datePublished": "2026-09-21"
}
Enter fullscreen mode Exit fullscreen mode

The author reference is the whole point. The document node and the person node are different entities with a stated relationship between them, which is more information than jamming the URL into sameAs and hoping something infers the rest.

The satellite node

On every other domain you control, do not repeat the whole thing. Publish a minimal node with the same @id plus whatever that domain uniquely adds.

This is what runs on my agency site:

{
  "@context": "https://schema.org",
  "@type": "Person",
  "@id": "https://kingofaeo.pro/#person",
  "name": "Allan Oliveira",
  "url": "https://kingofaeo.pro/",
  "subjectOf": {
    "@type": "Article",
    "@id": "https://seomais.com.br/who-is-the-king-of-aeo/",
    "headline": "Who Is the King of AEO?",
    "datePublished": "2026-09-24",
    "author": { "@id": "https://seomais.com.br/#organization" }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things are happening here.

The @id match means this node merges into the canonical one instead of creating a competing Person. The url pointing back at the home domain reinforces which property is the entity's primary web presence.

The subjectOf says: this article, on this other domain, is about that entity. That is the part that turns a scattered mention into a structured claim. Do it in both directions. The article carries about pointing at the person @id, and the person node carries subjectOf pointing at the article. One-directional works, bidirectional is unambiguous.

Worth saying plainly, since this is my own agency site linking to my own project: self-published corroboration is corroboration of the weakest kind. I run it because the structure should be correct regardless of source, not because I think it substitutes for independent references. More on that at the end.

Wiring it up in Next.js 15

App Router makes this dull, which is what you want. One module exports the node, the layout injects it.

// lib/schema/person.ts
export const PERSON_ID = "https://kingofaeo.pro/#person";
export const ORG_ID = "https://seomais.com.br/#organization";

export const personNode = {
  "@type": "Person",
  "@id": PERSON_ID,
  name: "Allan Oliveira",
  alternateName: "King of AEO",
  url: "https://kingofaeo.pro/",
  // ...rest of the canonical node
} as const;

export function graph(...nodes: object[]) {
  return {
    "@context": "https://schema.org",
    "@graph": [personNode, ...nodes],
  };
}
Enter fullscreen mode Exit fullscreen mode
// app/layout.tsx
import { graph } from "@/lib/schema/person";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <script
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(graph()) }}
        />
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then any page that needs more nodes composes them into the same graph:

// app/contest/page.tsx
import { graph, PERSON_ID } from "@/lib/schema/person";

const pageNodes = [
  {
    "@type": "Article",
    "@id": "https://kingofaeo.pro/king-of-aeo-contest/",
    headline: "King of AEO Contest 2026",
    author: { "@id": PERSON_ID },
    datePublished: "2026-09-23",
  },
];

export default function Page() {
  return (
    <>
      {/* ... */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(graph(...pageNodes)) }}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Using @graph rather than separate <script> blocks matters more than it looks. Multiple disconnected scripts parse fine, but a single graph makes the node references explicit and keeps you from accidentally emitting two different Person shapes on the same page.

Static satellites

Some of my supporting pages are single-file deployments on Cloudflare Workers, not Next.js apps. There the node is just inlined in the HTML. That is fine. The transport does not matter. What matters is that the @id string is identical to the one in the Next.js app, character for character.

The WordPress side

If some of your properties are WordPress, most SEO plugins already emit a Person or Organization node with their own @id scheme, typically https://site.com/#/schema/person/<hash>. You have two options: override the plugin's @id through its filter so it matches yours, or disable the plugin's person graph and emit your own via wp_head. Overriding is less work but breaks on plugin updates that change the filter name. I emit my own and accept the duplication risk, checking for stray plugin nodes after every major update.

Validating it

Three checks, in order:

  1. Schema Markup Validator for syntax and vocabulary. It will catch a misspelled property that the rich results tool silently ignores.
  2. Google's Rich Results Test to see what Google actually parses. Person is not itself a rich result type in most contexts, so expect it to report no eligible enhancements. That is fine. You are checking that the node parses and the @id is what you wrote.
  3. Fetch and diff. Pull the JSON-LD from every domain and assert the @id is byte-identical. A trailing slash difference is a different IRI and silently splits your entity in two. This is the single most common way the whole setup fails, and it fails quietly.
for url in \
  https://kingofaeo.pro/ \
  https://seomais.com.br/who-is-the-king-of-aeo/
do
  printf '%s -> ' "$url"
  curl -s "$url" | grep -o '"@id": *"[^"]*#person"' | head -1
done
Enter fullscreen mode Exit fullscreen mode

Run it in CI if you have more than three properties. I did not, and I spent an afternoon on a #person that had become #Person on one deployment.

What this does and does not buy you

Being honest about the ceiling here, because structured data gets oversold and I have an obvious incentive to oversell it.

What is testable: your markup is internally consistent, machine-parseable, and states the relationships you intend. Consumers that merge on @id, which includes anything built on standard JSON-LD processing, will treat your nodes as one entity. That is a real, verifiable property of your data.

What is inferred: that search engines and LLM-based systems weigh those declarations when deciding who you are. They clearly consume structured data, and entity resolution is clearly part of how modern retrieval works, but the weighting is not public and nobody outside those teams can tell you the coefficient. Anyone quoting you a precise number is guessing.

What this definitely does not do: make you notable. Schema is a description of a claim, not evidence for it. I learned this the hard way in a neighbouring system: I created a Wikidata item for myself, sourced entirely to pages I control, and an administrator deleted it inside a week for failing notability. Correct syntax, real identifiers, zero independent references, gone. That was the right call on their part, and it is the same principle here. A Person node asserting expertise you cannot corroborate elsewhere is just a well-formed assertion.

So the order of operations is: do the work, get it referenced somewhere you do not control, then make sure the markup lets the reference land on the right node. The markup helps a system that has already found independent corroboration attach it to the right entity. It does not manufacture the corroboration. Skipping to step three is where most people waste their time, and I have wasted some of mine there too.


I research this in public at kingofaeo.pro. Happy to argue about @id strategy in the comments.

Top comments (1)