DEV Community

Jangwook Kim
Jangwook Kim

Posted on • Originally published at jangwook.net

Catch Broken JSON-LD in CI Before It Ships

The structured-data check in your deploy pipeline is green. That green proves exactly one thing: your JSON-LD is well-formed. It does not prove Google can read a single field inside it. These are two different questions, and most teams treat them as one.

It clicked for me the day I watched a @type written as lowercase article sail through a parser without a peep. To a JSON-LD processor it's perfectly valid. To Google it's an unknown type, silently ignored. Nothing in between. No warning, no error, no red. You find out six months later, digging through Search Console to figure out why the rich result never showed.

Validation has two layers

When people say they "validate" structured data, they're usually pointing at two different things.

The first is syntactic validation. Does this JSON-LD parse? Are the braces balanced, is there a @context, can a JSON-LD 1.1 processor expand it into a graph? A library like jsonld nails this every time.

The second is schema-semantic validation. Is the type name a real schema.org term, in the right casing? Are the property names free of typos? Is the date ISO 8601? Are URL fields absolute? Does the node carry the properties Google recommends for that type? The parser won't tell you any of that.

Here's the trap: the second can fail while the first passes without blinking. And Google's own validators, the Rich Results Test and the Schema Markup Validator (validator.schema.org), are both manual, browser-based tools where you paste a URL or a blob of code. Neither lives in your build. So unless a human opens one by hand, broken schema flows straight to production.

If you've already decided which syntax to use among JSON-LD, Microdata, and RDFa, the next question is this: who checks that the markup you write in that syntax is actually correct, on every commit?

Why this gap costs more now

There was a time when silently broken structured data cost you, at most, a rich-result snippet. Not anymore. Search is shifting, and the crawlers that build AI overviews and generative answers lean harder on structured data to work out what a page means. Many of those crawlers don't run your JavaScript; they grab the raw HTML and leave. The JSON-LD your server emits is nearly all they see.

So what happens when that JSON-LD carries a lowercase article? To a human the page looks fine, and the parser waves it through, but to the machine reading it, that's an unidentified node with no author and no publish date. The price of a single slip has grown from "you miss a snippet" to "an AI misreads your page." Catching it before deploy pays off more than it used to.

Why the parser can't catch the typo

I didn't want to just assert this, so I reproduced it in a sandbox. Node v22, jsonld 8.x. I built a broken.json seeded with five everyday mistakes.

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "article",
      "headline": "Broken sample",
      "datePublished": "07/13/2026",
      "authour": "Kim Jangwook",
      "image": "hero.png"
    },
    {
      "@type": "BreadcrumbList",
      "itemListElement": [
        { "@type": "ListItem", "name": "Blog", "item": "https://example.com/blog" }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Lowercase article, the typo authour, a US-style date 07/13/2026, a relative hero.png, and a ListItem missing position. Run that through jsonld.expand() and you can see which IRI the processor resolves each term to.

$ node expand-demo.mjs

===== broken.json — jsonld.expand() =====
resolved @type IRIs : http://schema.org/article, http://schema.org/BreadcrumbList, ...
resolved term IRIs  : http://schema.org/article, http://schema.org/authour,
                      http://schema.org/datePublished, http://schema.org/headline, ...
Enter fullscreen mode Exit fullscreen mode

This is the whole point. article expands to http://schema.org/article, and authour expands to http://schema.org/authour, cleanly. No error. No warning. Nothing dropped.

The reason is that schema.org's hosted JSON-LD context sets @vocab to https://schema.org/. When @vocab is present, the processor just concatenates any undefined string onto that prefix. It never checks whether a property called authour exists in schema.org. It manufactures a nonexistent IRI, and that is entirely legal JSON-LD. The parser inspects syntax, not vocabulary.

That's where the gap between "valid JSON-LD" and "readable by Google" opens up. The same gap runs through wiring scattered blocks into one @graph: before you talk about connecting nodes, each node has to be written with a valid type and valid properties in the first place.

A 60-line schema-aware validator

If the parser won't catch it, bolt on a check that knows the schema. It doesn't need to be elaborate. A slice of vocabulary for the types you care about, plus five rules.

const VOCAB = {
  Article: {
    props: ['headline','datePublished','dateModified','author','image','description'],
    // Google lists NO required properties for Article (only recommended).
    // Enforcing headline is our team policy, not a Google rule.
    recommended: ['headline'],
    urlProps: ['image'], dateProps: ['datePublished','dateModified'],
  },
  BreadcrumbList: { props: ['itemListElement'], required: ['itemListElement'] },
  ListItem: { props: ['position','name','item'], required: ['position','name'], urlProps: ['item'] },
};
const KNOWN = Object.keys(VOCAB);
const ISO = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?([+-]\d{2}:\d{2}|Z)?)?$/;
const ABS = /^https?:\/\//;

function checkNode(node, errors) {
  let t = node['@type'];
  if (!KNOWN.includes(t)) {
    const near = KNOWN.find(k => k.toLowerCase() === String(t).toLowerCase());
    if (near) { errors.push(`@type "${t}" is wrong casing → "${near}"`); t = near; }
    else return;
  }
  const spec = VOCAB[t];
  for (const key of Object.keys(node)) {
    if (key.startsWith('@')) continue;
    if (!spec.props.includes(key)) {
      const near = spec.props.find(p => p.toLowerCase() === key.toLowerCase());
      errors.push(`${t}.${key}: not a valid property${near ? ` → "${near}"?` : ''}`);
    }
  }
  for (const r of (spec.required || [])) if (!(r in node)) errors.push(`${t}: missing required field "${r}"`);
  for (const d of (spec.dateProps || [])) if (node[d] && !ISO.test(node[d])) errors.push(`${t}.${d}: not ISO 8601`);
  for (const u of (spec.urlProps || [])) { const v = node[u]; if (v && !ABS.test(v)) errors.push(`${t}.${u}: not an absolute URL`); }
  // recurse into nested nodes and itemListElement
  for (const v of Object.values(node))
    (Array.isArray(v) ? v : [v]).forEach(x => x && typeof x === 'object' && x['@type'] && checkNode(x, errors));
}
Enter fullscreen mode Exit fullscreen mode

Notice that when it hits a casing error, it doesn't throw and stop. It recovers to the correct type and keeps checking. That way you see both that article is wrong and that the same node has an authour, a bad date, and a relative URL, all in one pass. My first cut skipped that recovery, reported the one type error, and missed the other four. CI has to show everything at once, or you burn a round trip per fix.

Look closely at Article being tagged recommended rather than required. Per Google's own docs, Article has no required properties. author, datePublished, dateModified, headline, and image are all merely recommended. So enforcing headline is a call your team makes, not a rule Google imposes. That's exactly what a validator is for: encoding "the floor our org sets on top of Google's recommendations" as code.

What the run actually produced

I fed good.json (a clean Article plus a two-step BreadcrumbList) and broken.json into the same validator.

Real CI run log of the structured-data validator. good.json returns PASS with 0 problems; broken.json returns FAIL with 5 problems, catching the casing error, the property typo, the bad date, the relative URL, and the missing required field, then exits 1 to block the build

===== good.json =====
PASS — 0 problems

===== broken.json =====
FAIL — 5 problems
  x @type "article" is wrong casing → "Article"
  x Article.authour: not a valid property → "author"
  x Article.datePublished: "07/13/2026" is not ISO 8601
  x Article.image: "hero.png" must be an absolute URL
  x ListItem: missing Google-required field "position"
process exit code = 1
Enter fullscreen mode Exit fullscreen mode

All five caught, and the process ended on broken.json with exit 1. That exit code is the whole game. good.json exits 0. With that one line, CI blocks the build with no extra configuration.

Notice only the ListItem missing position is labeled "Google-required." That's accurate. A BreadcrumbList requires at least two ListItems, and each ListItem genuinely requires position and name (official). None of the four Article errors carry a "required" tag. The validator is speaking precisely, keeping Google's rules and your team's policy on separate lines.

Wiring it into a CI gate

Since the exit code is already 1, the rest is plumbing. One script in package.json.

{ "scripts": { "validate:schema": "node scripts/validate-schema.mjs" } }
Enter fullscreen mode Exit fullscreen mode

And one step in a GitHub Actions job.

- name: Validate structured data
  run: npm run validate:schema
Enter fullscreen mode Exit fullscreen mode

If the validator fails, the job fails, and a PR carrying broken schema doesn't merge. To sweep the whole site, scrape every <script type="application/ld+json"> block out of the built HTML and pipe each into the same checkNode. Same principle. It's the same skeleton as putting accessibility checks in CI: take the thing a human used to eyeball by hand and turn it into a deterministic gate that goes red on failure.

What this validator can't do

The post is only honest if I draw the line clearly.

This is not a replacement for the Rich Results Test. It knows a hand-picked slice of vocabulary (Article, BreadcrumbList, ListItem, Person). For real coverage you'd generate the type and property lists from schema.org's public dump and fill VOCAB from that. What you saw here is a proof of concept, not a finished product.

Passing validation does not guarantee a rich result. That's not my opinion, it's Google's official stance. The General Structured Data Guidelines say it plainly: using structured data "enables a feature to be present, it does not guarantee that it will be present." Even with flawless markup, Google's algorithm may decide, based on the user, device, or location, that a plain text result is better. The same guidelines state that structured data "by itself is not a generic ranking factor." What the validator passes is "the shape is correct," not "a rich result will appear" and certainly not "your ranking will rise."

Expansion only sees syntax. As shown above, @vocab expands even a typo into a valid IRI. So don't mistake a successful expansion for validation. The two layers don't substitute for each other. Leave syntax to the parser and meaning to a schema-aware check.

What to do today

  • Paste your build's JSON-LD into the Rich Results Test once by hand to set a baseline. Then move that check into code.
  • Start VOCAB with the types you actually use most (usually Article/BlogPosting, BreadcrumbList, Organization, WebSite). Don't try to fill everything; start with what you get wrong.
  • Always check the four things a parser will never flag: casing, property typos, date format, and relative URLs.
  • Label Google's "required" and your team's "policy" separately in code. Later, nobody wonders why a field is enforced.
  • Bind exit code 1 to the CI step. A check that only prints a report and passes is a check nobody reads.

If you want structured data emitted reliably server-side, or an existing site's schema, accessibility, and crawler handling reviewed at the pipeline level, I take on consulting and implementation work personally. Reach me through the contact link on my profile. Looking behind the green check is the work I do.

Top comments (0)