DEV Community

Cover image for Your JSON-LD says one thing and your page says another. Test it in CI
Redlio Labs
Redlio Labs

Posted on AI-assisted

Your JSON-LD says one thing and your page says another. Test it in CI

Structured data has a habit of drifting away from the page it describes. Someone edits a headline in the CMS, and the JSON-LD still carries the old one. A template change turns the logo into a relative path. A second Organization block appears on the blog with a slightly different company name, and now search engines see two versions of you.

None of this breaks the page, so nobody notices. Google's Rich Results Test will catch some of it, but it checks one URL at a time, and nobody pastes fifty URLs into it before every deploy.

So we test it in CI instead, the same way we'd test anything else that can quietly regress. Here's the script, what it checks, and why each check is there.

What "broken" means for JSON-LD

There are two kinds of broken.

Invalid: the JSON doesn't parse, @context is wrong, a required property is missing. Validators catch this.

Wrong: the JSON is perfectly valid but says something the page doesn't. Validators can't catch this, because they don't compare the markup with what a reader sees. Google's general structured data guidelines are clear that markup should describe content that is visible on the page, and that a mismatch can make a page ineligible for rich results.

The second kind is the one that drifts, so most of the checks below are about it.

The checks

  1. Every application/ld+json block parses. One trailing comma and the whole block is ignored.
  2. @context is schema.org.
  3. Article headline matches the visible <h1>. It's an easy one to miss: the headline gets edited in the CMS and the markup keeps the old one.
  4. Dates are valid ISO 8601, and dateModified isn't before datePublished.
  5. Articles have an author.
  6. Breadcrumb and logo URLs are absolute https:// URLs. Relative paths are a classic template bug.
  7. Every FAQ question in the markup appears in the page text.
  8. The Organization is declared the same way on every page. Same name, same url, character for character.

That last one matters more than it looks. Search engines and AI assistants both try to resolve your pages into one entity: one company, with one name and one website. Inconsistent Organization markup across pages is a small, self-inflicted contradiction in that picture, and it's trivial to prevent.

The script

It uses Playwright, so it reads the rendered DOM. That matters if your JSON-LD is injected client-side by a framework or a tag manager: a plain fetch of the HTML would miss it.

// check-jsonld.mjs — run: node check-jsonld.mjs https://example.com/ https://example.com/blog/post
import { chromium } from "playwright";

const urls = process.argv.slice(2);
const problems = [];
const orgs = new Map(); // "name|url" -> pages that declare it

const flag = (page, msg) => problems.push(`${page}\n  ✗ ${msg}`);
const isAbsHttps = (u) => typeof u === "string" && /^https:\/\//.test(u);
const types = (n) => [].concat(n?.["@type"] ?? []);
const norm = (s) => String(s ?? "").replace(/\s+/g, " ").trim().toLowerCase();

// Flatten @graph arrays and nested objects so every typed node gets checked
function nodes(data, out = []) {
  if (Array.isArray(data)) data.forEach((d) => nodes(d, out));
  else if (data && typeof data === "object") {
    if (data["@type"]) out.push(data);
    for (const [k, v] of Object.entries(data)) if (k !== "@context") nodes(v, out);
  }
  return out;
}

const browser = await chromium.launch();
const page = await browser.newPage();

for (const url of urls) {
  const res = await page.goto(url, { waitUntil: "networkidle" });
  if (!res || res.status() >= 400) { flag(url, `HTTP ${res?.status()}`); continue; }

  // Rendered DOM, so JSON-LD injected by client-side JS is included
  const blocks = await page.$$eval('script[type="application/ld+json"]', (s) => s.map((x) => x.textContent));
  const h1 = await page.$eval("h1", (h) => h.innerText).catch(() => "");
  const visible = norm(await page.evaluate(() => document.body.innerText));

  if (blocks.length === 0) { flag(url, "no JSON-LD found"); continue; }

  const all = [];
  blocks.forEach((raw, i) => {
    try {
      const data = JSON.parse(raw);
      const ctx = JSON.stringify(data["@context"] ?? data[0]?.["@context"] ?? "");
      if (!ctx.includes("schema.org")) flag(url, `block ${i + 1}: @context is not schema.org`);
      all.push(...nodes(data));
    } catch (e) {
      flag(url, `block ${i + 1}: invalid JSON (${e.message})`);
    }
  });

  for (const n of all) {
    const t = types(n);

    if (t.includes("Organization") || t.includes("LocalBusiness")) {
      const key = `${n.name}|${n.url}`;
      orgs.set(key, [...(orgs.get(key) ?? []), url]);
      if (n.logo && !isAbsHttps(n.logo.url ?? n.logo)) flag(url, `Organization logo is not an absolute https URL`);
    }

    if (t.some((x) => ["Article", "BlogPosting", "NewsArticle"].includes(x))) {
      if (norm(n.headline) !== norm(h1)) flag(url, `Article headline "${n.headline}" ≠ visible h1 "${h1}"`);
      for (const f of ["datePublished", "dateModified"])
        if (n[f] && Number.isNaN(Date.parse(n[f]))) flag(url, `${f} "${n[f]}" is not a valid ISO 8601 date`);
      if (n.datePublished && n.dateModified && Date.parse(n.dateModified) < Date.parse(n.datePublished))
        flag(url, "dateModified is earlier than datePublished");
      if (!n.author) flag(url, "Article has no author");
    }

    if (t.includes("BreadcrumbList")) {
      for (const li of [].concat(n.itemListElement ?? [])) {
        const item = li.item?.["@id"] ?? li.item;
        if (item && !isAbsHttps(item)) flag(url, `breadcrumb "${li.name}" item is not an absolute https URL: ${item}`);
      }
    }

    if (t.includes("FAQPage")) {
      for (const q of [].concat(n.mainEntity ?? []))
        if (!visible.includes(norm(q.name))) flag(url, `FAQ question not visible on page: "${q.name}"`);
    }
  }
}

// One company, one identity: every page should declare the same Organization
if (orgs.size > 1) {
  problems.push(`Organization declared ${orgs.size} different ways:`);
  for (const [key, pages] of orgs) problems.push(`  ${key}  ← ${pages.length} page(s), e.g. ${pages[0]}`);
}

await browser.close();

if (problems.length) {
  console.error(problems.join("\n"));
  console.error(`\n${problems.length} problem(s) found`);
  process.exit(1);
}
console.log(`✓ JSON-LD checks passed on ${urls.length} page(s)`);
Enter fullscreen mode Exit fullscreen mode

It exits with code 1 when it finds anything, so CI fails the build.

What it catches

We ran it against two small test pages: one clean, and one with the kinds of mistakes that commonly turn up in audits. This is the actual output:

http://localhost:8765/bad.html
  ✗ block 2: invalid JSON (Expected double-quoted property name in JSON at position 52 (line 1 column 53))
http://localhost:8765/bad.html
  ✗ Organization logo is not an absolute https URL
http://localhost:8765/bad.html
  ✗ Article headline "Ship it on Friday (draft)" ≠ visible h1 "Ship it on Friday"
http://localhost:8765/bad.html
  ✗ dateModified is earlier than datePublished
http://localhost:8765/bad.html
  ✗ Article has no author
http://localhost:8765/bad.html
  ✗ breadcrumb "Blog" item is not an absolute https URL: /blog
http://localhost:8765/bad.html
  ✗ FAQ question not visible on page: "Is it safe to ship on Friday?"
Organization declared 2 different ways:
  Acme Labs|https://acme.test/  ← 1 page(s), e.g. http://localhost:8765/good.html
  Acme Labs Inc|https://acme.test  ← 1 page(s), e.g. http://localhost:8765/bad.html

10 problem(s) found
Enter fullscreen mode Exit fullscreen mode

Look at the last check. The two Organization entries differ by an "Inc" and a trailing slash. Both blocks are valid. A validator passes both. To a machine trying to work out who you are, they are still two slightly different claims.

Running it in CI

Pick a handful of URLs that cover each template: the home page, a service page, a blog post, a page with breadcrumbs, a page with an FAQ. Templates are where JSON-LD comes from, so one URL per template covers most of the site.

Run it against your preview deployment, before the change reaches production. In GitHub Actions:

# .github/workflows/jsonld.yml
name: JSON-LD checks
on: [deployment_status]

jobs:
  jsonld:
    if: github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Check structured data on the preview
        env:
          BASE: ${{ github.event.deployment_status.environment_url }}
        run: |
          node check-jsonld.mjs \
            "$BASE/" \
            "$BASE/services/web-development" \
            "$BASE/blog/some-post" \
            "$BASE/contact"
Enter fullscreen mode Exit fullscreen mode

Some hosts, Vercel for example, report preview deployments through GitHub's deployment status, which is what the on: [deployment_status] trigger listens for. If yours doesn't, run the script after your own deploy step instead.

What this doesn't replace

This is a regression test for your own templates. It doesn't know Google's full list of required and recommended properties for each rich result type, and that list changes. In June 2025, for example, Google announced it was phasing out seven structured data features, including Course Info, Claim Review and Vehicle Listing. It said the leftover markup wouldn't cause Search Console errors, but those types no longer earn a rich result.

So keep using the Rich Results Test and the Schema Markup Validator when you add a new type, and watch the Enhancements reports in Search Console. The script's job is narrower: make sure nothing you already got right quietly goes wrong on the next deploy.

A short checklist

  • [ ] Every JSON-LD block on every template parses
  • [ ] Article headlines match the visible <h1>
  • [ ] Dates are ISO 8601 and in the right order
  • [ ] Logo and breadcrumb URLs are absolute https://
  • [ ] FAQ markup only contains questions that are on the page
  • [ ] One Organization, declared identically everywhere
  • [ ] The script runs on every preview deployment and fails the build

Consistent entity data is one of the cheaper things you can do for how both search engines and AI assistants describe your company. It's part of what we check in an SEO audit, and something we look at early in generative engine optimization work, since an assistant can only cite a company it has resolved correctly.

Your turn: what's the strangest structured data drift you've found in production? We'd like to hear about the ones a validator passed.

From the team at Redlio Labs, where we build and grow websites and web applications.

Top comments (0)