Schema for the Code-Curious: JSON-LD, the @graph & Code You Can Ship

For the Code-Curious Schema in raw JSON-LD Hand-write it, connect the graph, generate it, validate it. Code, not generators. Show me how →
Your journey cost
Tick the steps you want — total updates live
Total
Live prices · pay as you go
Pricing comparison
PAYG vs Subscription
PAYG
£0 /mo min

Top up from £4.99 · credits never expire

Subscription

Select a plan to compare.

£4.99/mo
Compare against plan:
Calculating…

Schema for the code-curious: JSON-LD, the @graph and code you can ship

This guide is for the code-curious who would rather write structured data than click through a generator. It assumes you are comfortable with HTML and a little JavaScript. We will cover the anatomy of a JSON-LD block, how to connect everything into a single @graph, the geo and openingHoursSpecification blocks validators keep asking for, fixing common errors with find-and-replace, generating markup dynamically from your data, and validating every page in CI. For the strategy behind all of this, see our advanced schema and schema mastery guides.

Anatomy of a JSON-LD block

Schema markup is just a JSON object describing the page, dropped into a script tag in the <head>. Every block has an @context (always Schema.org), an @type, and the properties that type supports:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "How to ship JSON-LD by hand",
  "datePublished": "2026-06-01",
  "dateModified": "2026-06-01",
  "author": { "@type": "Person", "name": "Jane Dev" }
}
</script>

That is the whole idea. Pick the right @type, fill the properties from the page’s real content, and put it in the head. Read one block and you can write any of the 800-plus types — the structure never changes, only the type and its properties.

Connect everything with @graph

Shipping separate blocks per entity is the beginner mistake. Use a single @graph array where each node has an @id and other nodes reference it by that id, so you define each entity once and express how they relate:

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Organization",
      "@id": "https://example.com/#org",
      "name": "Example Ltd",
      "url": "https://example.com/",
      "sameAs": [
        "https://x.com/example",
        "https://www.linkedin.com/company/example"
      ]
    },
    {
      "@type": "WebPage",
      "@id": "https://example.com/post#webpage",
      "url": "https://example.com/post",
      "name": "Post title",
      "isPartOf": { "@id": "https://example.com/#org" },
      "author": { "@id": "https://example.com/#org" }
    }
  ]
}
</script>

Now the WebPage’s author and isPartOf point at one canonical Organization node rather than repeating it. No contradictions, less markup, and Google gets one coherent model instead of fragments to reconcile.

Add geo and opening hours (the bit the debugger nags about)

If you run a LocalBusiness through a validator and see warnings for geo and openingHoursSpecification, those are the two properties to add. They are plain sub-objects — coordinates and a list of day/time ranges:

{
  "@type": "LocalBusiness",
  "name": "Example Ltd",
  "geo": {
    "@type": "GeoCoordinates",
    "latitude": 53.235,
    "longitude": -1.421
  },
  "openingHoursSpecification": [
    {
      "@type": "OpeningHoursSpecification",
      "dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
      "opens": "09:00",
      "closes": "17:00"
    }
  ]
}

Use your real coordinates and real hours — both are factual claims about the business, so never invent them. One rule that overrides everything in this book: only mark up genuine, visible data. Fabricating an aggregateRating or fake reviews to clear a warning is exactly what triggers a manual action, so leave optional properties out rather than faking them.

Fix a common error with find-and-replace

Most schema fixes are a one-property edit repeated across many files — perfect for find-and-replace. Say your WebPage block is missing author. The change is to append one property after the last existing one. Before:

"dateModified": "2026-06-01"
}

After:

"dateModified": "2026-06-01",
"author": { "@id": "https://example.com/#org" }
}

Across a directory of static files, that is a single anchored substitution. Always back up first and match a unique string so you cannot hit the wrong line:

sed -i.bak \
  's|"dateModified": "2026-06-01"|"dateModified": "2026-06-01",\n  "author": {"@id":"https://example.com/#org"}|' \
  *.html

The .bak suffix writes a backup of every file so you can roll back with for f in *.bak; do mv "$f" "${f%.bak}"; done. Anchor on a string that appears exactly once per file; if it is not unique, narrow it until it is.

Generate it dynamically

Hand-write a few blocks; template the rest from your data so every page of a type emits correct markup automatically. A minimal generator is just a function returning a stringified object:

const ORG_ID = "https://example.com/#org";

function articleSchema(post) {
  const data = {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": post.title,
    "datePublished": post.published,
    "dateModified": post.updated,
    "author": { "@id": ORG_ID }
  };
  // only include optional fields when real data exists
  if (post.ratingCount > 0) {
    data.aggregateRating = {
      "@type": "AggregateRating",
      "ratingValue": post.ratingValue,
      "ratingCount": post.ratingCount
    };
  }
  return `<script type="application/ld+json">${JSON.stringify(data)}</script>`;
}

Two things make templated schema safe: populate every field from the page’s real data so markup and content can never drift, and guard optional fields (like aggregateRating) behind a check so a record with no reviews emits no rating rather than an empty or fake one. The win is that the integrity rule scales — but so does any mistake, which is why validation matters.

Validate every page in CI

Because templated schema is code, a broken block is a production bug replicated across every page. Catch it before it ships. The cheapest gate just extracts each JSON-LD block and parses it — invalid JSON throws and fails the build:

node -e '
const fs = require("fs");
const html = fs.readFileSync(process.argv[1], "utf8");
const re = /<script type="application\/ld\+json">([\s\S]*?)<\/script>/g;
let m, n = 0;
while ((m = re.exec(html))) { JSON.parse(m[1]); n++; }
console.log("ok:", n, "blocks");
' page.html

Wire that across your built pages in CI, and for deeper checks run representative pages — including edge cases like a product with no reviews — through the Schema Debugger when you change a template. Generate starting blocks fast with the AI Schema Generator and build connected structures with the Schema Builder, then own the markup in code.

A worked example

A coder inherits a blog where every post has a standalone Article block and a separate Organization block, duplicated and slightly inconsistent on every page, and the validator flags missing authorship. They refactor to a single @graph: Organization defined once with a stable @id, each Article referencing it as author and publisher. They move the markup into the template so it is generated from post data, guard aggregateRating behind a review-count check, and add the one-line JSON-parse gate to CI plus a Schema Debugger pass on a sample of posts when the template changes. The duplication is gone, authorship resolves, and a malformed block can no longer reach production. None of it required a plugin — just code they control.

Common mistakes to avoid

Shipping disconnected blocks instead of a connected @graph. Redefining the same entity differently on every page. Faking aggregateRating, reviews or FAQs to clear a warning — a manual-action risk, especially when templated across a whole site. Templating optional fields without guarding them, so edge-case records emit empty or invalid markup. Editing with an un-anchored find-and-replace that hits the wrong line (always back up and match a unique string). And shipping generated schema with no validation gate, so one template break propagates everywhere.

Frequently asked questions

Do I need a plugin to add schema?

No. JSON-LD is a JSON object in a <script type="application/ld+json"> tag in the head. You can write it by hand or generate it in your template — no plugin required.

What is the @graph and why use it?

A single array where each entity has an @id and others reference it by that id. It lets you define each entity once and express relationships, so Google gets one coherent model instead of disconnected, possibly contradictory blocks.

How do I add geo and opening hours to LocalBusiness?

Add a geo object with latitude and longitude, and an openingHoursSpecification array of day/time ranges. Use your real coordinates and hours — never invent them.

Can I fake aggregateRating to clear a warning?

No. Fabricated ratings or reviews are a manual-action risk, and templating them applies the violation sitewide. Omit optional properties unless you have genuine data.

How do I generate schema dynamically?

Build the markup in your template layer from real page data, returning a stringified JSON-LD object, and guard optional fields behind a check so records without that data emit nothing rather than empty or fake values.

How do I validate schema in a build pipeline?

At minimum, extract each JSON-LD block and run JSON.parse so invalid JSON fails the build. For deeper checks, run representative pages including edge cases through a schema debugger whenever a template changes.