SEO for Solos
Chapter 04Free

The schema graph

One script, one graph, stable identifiers. How to describe 2,100 pages as one entity rather than 2,100 unrelated documents, and the test that proves the references resolve.

4 min read · updated 2026-07-26· last reviewed 2026-07-26

One script per page, one graph inside it

The principle

Three separate <script type="application/ld+json"> tags describe three unrelated things that happen to be on the same page. One @graph array describes one page made of related parts, and it lets nodes reference each other by identifier instead of repeating themselves.

The difference compounds with scale. With separate scripts, every page ships its own copy of the Organization node, and a crawler has no way to know that the 2,100 copies are the same organisation. With a graph and stable identifiers, one entity is asserted 2,100 times from 2,100 pages, all pointing at the same @id.

The implementation

typescript
export function siteGraph(opts: SiteGraphOptions): JsonLdObject {
  const orgId = nodeId(opts.siteUrl, "#organization");
  const nodes = [
    organization({ siteUrl: opts.siteUrl, ...opts.organization }),
    website({ siteUrl: opts.siteUrl, publisher: ref(orgId), ...opts.website }),
    opts.product ? product({ siteUrl: opts.siteUrl, ...opts.product }) : undefined,
    ...(opts.additionalNodes ?? []),
  ];

  return {
    "@context": "https://schema.org",
    "@graph": dedupeById(nodes.filter(isPresent)),
  };
}

Three anchors, and they never change:

  • #organization is the publisher of #website and the brand behind #product
  • #website is the isPartOf target for every page node on the site
  • #product is the thing being sold

Page-level nodes, WebPage and BreadcrumbList and Article, reference those three rather than restating them.

The test that makes the graph trustworthy

The principle

A graph is only useful if its references resolve. A node referencing #logo when no node declares #logo is a dangling pointer: it looks correct to a human reading the JSON and means nothing to a parser.

This is easy to break by accident and easy to catch automatically, which makes it exactly the kind of thing that belongs in a test rather than in a review checklist.

The implementation

The subtlety is distinguishing a declaration from a reference. An object carrying @id plus other properties declares a node, at any nesting depth. An object carrying nothing but @id references one.

typescript
export function graphIdIntegrity(graph: JsonLdObject) {
  const declared = new Set<string>();
  const referenced = new Set<string>();

  const walk = (value: unknown): void => {
    if (Array.isArray(value)) { for (const item of value) walk(item); return; }
    if (!value || typeof value !== "object") return;

    const obj = value as Record<string, unknown>;
    const id = typeof obj["@id"] === "string" ? obj["@id"] : undefined;
    if (id) {
      if (Object.keys(obj).length === 1) referenced.add(id);
      else declared.add(id);
    }
    for (const [key, val] of Object.entries(obj)) {
      if (key === "@id") continue;
      walk(val);
    }
  };

  for (const node of graph["@graph"] ?? []) walk(node);
  return { declared, referenced, dangling: [...referenced].filter((id) => !declared.has(id)) };
}
typescript
it("resolves every @id reference to a node in the same graph", () => {
  expect(graphIdIntegrity(siteGraph()).dangling).toEqual([]);
});

Duplicate identifiers

The principle

Two nodes with the same @id in one graph is a validation error and an ambiguous claim. It happens the moment two composers both decide to add the Organization node, which is the natural result of building the graph in more than one place.

Dedupe at composition time rather than auditing for it later.

The implementation

typescript
function dedupeById(nodes: JsonLdObject[]): JsonLdObject[] {
  const seen = new Map<string, JsonLdObject>();
  const anonymous: JsonLdObject[] = [];
  for (const node of nodes) {
    const id = typeof node["@id"] === "string" ? node["@id"] : undefined;
    if (id) seen.set(id, node);
    else anonymous.push(node);
  }
  return [...seen.values(), ...anonymous];
}

Last write wins, which is the right default: a page-level node with more specific information should override a generic one composed earlier.

bash
curl -s https://example.com/ \
  | grep -o '<script type="application/ld+json">[^<]*' \
  | sed 's/.*json">//' \
  | jq '[."@graph"[]."@id"] | group_by(.) | map(select(length > 1))'

Checks this chapter covers

Each one has a command you can run against your own site.

Product analytics are optional. On the skill page, X advertising measurement is also optional and shares your visit and ad identifiers with X. Both are off until you allow them. Privacy policy.