Validating JSON-LD in CI for Headless Builds

Structured data fails silently: the page renders, the build passes, and rich results quietly stop appearing weeks later. A build gate is the only place the failure is cheap.

When to Use This Approach

Add this gate when:

  • Rich results have disappeared for a template and nobody can say which change caused it.
  • Structured data is assembled in components, so any refactor can drop a property.
  • Multiple teams contribute templates and no single person reviews the emitted graph.

Four Checks, In Order

Each check catches a class the previous one cannot see, and the order matters because a failure at one level makes the next meaningless.

The four validation stages and what each catches Presence in the server response, then JSON and graph validity, then required properties per rich-result type, then agreement between structured data and visible content. 1. present in server HTML 2. valid parses, refs resolve 3. complete required properties 4. truthful matches the page catches client-only injection catches broken serialisation catches lost properties catches misleading markup Stage 4 is the one with a manual-action risk attached, not just a lost feature

Implementation Steps

Step 1: Extract from the server HTML

// ci/extract-jsonld.mjs
export async function extractJsonLd(url) {
  const res = await fetch(url, { headers: { 'user-agent': 'ci-structured-data-check' } });
  const html = await res.text();
  const blocks = [...html.matchAll(
    /<script[^>]+type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g,
  )].map((m) => m[1]);
  return { url, status: res.status, blocks };
}

SEO impact: Fetching without a browser is the whole point — structured data injected during hydration will produce zero blocks here and fail, which is the correct outcome.

Validation: Run against a page you know is fine and confirm a non-zero block count.

Step 2: Validate JSON and graph shape

// ci/validate-graph.mjs
export function validateGraph(blocks) {
  const errors = [];
  const nodes = [];

  for (const [i, raw] of blocks.entries()) {
    let parsed;
    try {
      parsed = JSON.parse(raw);
    } catch (e) {
      errors.push({ block: i, error: `unparseable JSON: ${e.message}` });
      continue;
    }
    if (!parsed['@context']) errors.push({ block: i, error: 'missing @context' });
    nodes.push(...(parsed['@graph'] ?? [parsed]));
  }

  const ids = new Set(nodes.map((n) => n['@id']).filter(Boolean));
  for (const node of nodes) {
    for (const [key, value] of Object.entries(node)) {
      const ref = value?.['@id'];
      if (ref && ref.startsWith('#') && !ids.has(ref)) {
        errors.push({ type: node['@type'], error: `unresolved reference ${key} -> ${ref}` });
      }
    }
  }
  return { nodes, errors };
}

Validation: Introduce a dangling @id reference on a branch and confirm it is reported.

Step 3: Assert required properties per type

// ci/required-properties.mjs
export const REQUIRED = {
  Article:    ['headline', 'datePublished', 'author'],
  Product:    ['name', 'image', 'offers'],
  Offer:      ['price', 'priceCurrency', 'availability'],
  Recipe:     ['name', 'image', 'recipeIngredient', 'recipeInstructions'],
  FAQPage:    ['mainEntity'],
  BreadcrumbList: ['itemListElement'],
  HowTo:      ['name', 'step'],
};

export function checkRequired(nodes) {
  const errors = [];
  for (const node of nodes) {
    const types = [].concat(node['@type'] ?? []);
    for (const t of types) {
      for (const prop of REQUIRED[t] ?? []) {
        if (node[prop] === undefined || node[prop] === null || node[prop] === '') {
          errors.push({ type: t, missing: prop });
        }
      }
    }
  }
  return errors;
}

SEO impact: A syntactically perfect graph missing datePublished is ineligible for the rich result it was written for, and nothing in the page or the build would otherwise say so.

Validation: Remove a required property in a component and confirm the specific type and property are named in the failure.

Step 4: Cross-check against the visible page

// ci/cross-check.mjs — structured data must describe what the page shows
import { JSDOM } from 'jsdom';

export function crossCheck(html, nodes) {
  const { document } = new JSDOM(html).window;
  const h1 = document.querySelector('h1')?.textContent?.trim() ?? '';
  const errors = [];

  const article = nodes.find((n) => [].concat(n['@type'] ?? []).includes('Article'));
  if (article && h1 && normalise(article.headline) !== normalise(h1)) {
    errors.push({ error: 'headline does not match h1', headline: article.headline, h1 });
  }

  const faq = nodes.find((n) => [].concat(n['@type'] ?? []).includes('FAQPage'));
  if (faq) {
    const visible = [...document.querySelectorAll('summary, h3')].map((e) => normalise(e.textContent));
    for (const q of faq.mainEntity ?? []) {
      if (!visible.some((v) => v.includes(normalise(q.name).slice(0, 40)))) {
        errors.push({ error: 'FAQ question not visible on the page', question: q.name });
      }
    }
  }
  return errors;
}

const normalise = (s) => (s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();

SEO impact: Structured data describing content that is not on the page is a policy violation, not merely an inaccuracy, and it carries a manual-action risk that the other three checks do not.

Validation: Add an FAQ entry to the graph that is not rendered and confirm it is reported.

Step 5: Wire it into the pipeline

# .github/workflows/structured-data.yml
name: structured data
on: [pull_request]
jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build && npm run start &
      - run: npx wait-on http://localhost:3000
      - run: node ci/validate.mjs ci/representative-urls.txt
// ci/validate.mjs — one representative URL per template
const failures = [];
for (const url of urls) {
  const { blocks, status } = await extractJsonLd(url);
  if (status !== 200) { failures.push({ url, error: `status ${status}` }); continue; }
  if (!blocks.length) { failures.push({ url, error: 'no JSON-LD in server HTML' }); continue; }
  const { nodes, errors } = validateGraph(blocks);
  failures.push(...errors.map((e) => ({ url, ...e })));
  failures.push(...checkRequired(nodes).map((e) => ({ url, ...e })));
}
if (failures.length) {
  console.table(failures);
  process.exit(1);
}

Validation: The job must have failed at least once for a real defect before you can trust it.

What Each Check Buys

The four checks have very different costs and very different payoffs, which is worth knowing when deciding how far to go.

Cost and payoff of each validation check Four checks with their implementation cost and the consequence each one prevents, from lost rich results to a manual action. check cost prevents present in server HTML an hour structured data never read JSON and graph validity an hour whole block discarded required properties half a day rich result silently lost matches visible content a day manual action The first two cost almost nothing and catch the most common failures

SEO Impact Summary

The gate is worth having only if it can fail, so the first thing to do after wiring it up is to break something on a branch and watch it catch the break.

Proving the gate fails before trusting it A branch with a deliberately removed required property producing a failing build, confirming the assertion works. remove a required property on a branch CI runs build fails naming the property A gate that has never failed has never been tested
Signal What improves What breaks if misconfigured
Rich result eligibility Required properties are guaranteed present on every template A silently dropped property removes the feature with no error anywhere
First-pass reading Structured data is in the server HTML and read without rendering Client-injected markup depends on the render queue succeeding
Policy compliance Structured data never describes content the page does not show Mismatched markup is a manual-action risk, not merely a defect
Regression safety A refactor that drops a property fails before merge Without the gate, the loss is discovered weeks later in a traffic report

Measurable signals to watch:

  • CI failure count for structured data, which should be non-zero occasionally — a gate that never fires is not testing anything.
  • Valid item counts per type in the Search Console enhancement reports.
  • Rich result impressions per template, watched after any template change.

Edge Cases and Gotchas

One representative URL per template is enough, until it is not Pages of the same template share their graph shape, so per-template coverage catches structural defects. Content-dependent defects — a product with no image, an article with no author — need a separate production audit over real data.

@graph and multiple blocks are both valid Some tools prefer a single @graph; others accept several blocks. Both are read correctly, so validate the union of all blocks rather than assuming one shape.

Escaping is a real failure mode Serialising with JSON.stringify into a script tag can break on a </script> sequence inside content. Escape the forward slash, or serialise through a helper that does.

Validate the built output, not the source A template that looks correct can emit nothing if the component tree changed. Always fetch from a running build, which is the same discipline as comparing rendered vs source HTML.

Frequently Asked Questions

Why not just use the Rich Results Test? It is right for confirming eligibility on a specific URL, but it renders the page, so it approves structured data that only exists after hydration. It also cannot run per pull request across every template. Use it for eligibility and CI for regressions.

Should validation fail the build or only warn? Fail for anything that makes a page ineligible for a rich result it currently earns — a missing required property, unparseable JSON, or absence from the server HTML. Warn for recommended properties. A warning that has never blocked anything is documentation.

How do I validate structured data that depends on live data? Validate shape, not values. Asserting a specific price is a flaky test; asserting a price exists, is numeric, and has a currency is stable and catches every real defect. Keep value checks for production monitoring.

Does more structured data mean better results? No. Adding types you do not have content for produces incomplete graphs and, at worst, markup that misdescribes the page. Emit the types the page genuinely is, completely, and stop there — the reasoning is developed in structured data for headless.


Part of: Structured Data for Headless

Related