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.
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.
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.
| 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
- Injecting JSON-LD at the Render Layer in Headless — putting the markup where this gate can find it
- Integrating Lighthouse CI Into Headless Pipelines — the pipeline this check belongs alongside
- Comparing Rendered vs Source HTML for Headless Pages — the broader server-versus-client parity check