How to check json ld

Extract every <script> whose type is exactly application/ld+json, run each body through JSON.parse, and print what failed. A block with any other type attribute is never parsed, and one trailing comma drops the whole block. Neither failure is reported by the browser or by the server.

Why check this

Run this after any change to the document head, and on every page template before release. Three failures here are mechanical, invisible in a rendered page, and each costs the whole block rather than one field.

The type attribute has to read application/ld+json and nothing else. A block marked application/json holds correct, parseable content that no consumer of structured data opens. A single trailing comma makes the block invalid JSON, so it is dropped whole. And @context decides what every term means: point it elsewhere and name stops being https://schema.org/name, leaving a block that parses cleanly and says nothing recognisable.

Prerequisites

// fixtures.js
const http = require('http'), fs = require('fs'), path = require('path');
const TYPES = { '.html': 'text/html; charset=utf-8', '.png': 'image/png' };
http.createServer((req, res) => {
  const url = req.url.split('?')[0];
  if (url === '/img/moved.png') return res.writeHead(302, { location: '/img/card.png' }).end();
  if (url === '/img/missing.png') return res.writeHead(404, { 'content-type': 'text/html' }).end('<h1>404</h1>');
  const file = path.join(__dirname, 'fixtures', path.normalize(url).replace(/^[\\/]+/, ''));
  if (!file.startsWith(path.join(__dirname, 'fixtures')) || !fs.existsSync(file)) {
    return res.writeHead(404).end('not found');
  }
  const body = fs.readFileSync(file);
  res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream',
                       'content-length': body.length });
  res.end(body);
}).listen(8919, '127.0.0.1', () => console.log('fixtures on http://127.0.0.1:8919, pid ' + process.pid));
<!-- fixtures/jsonld.html: one correct block and three ways to lose one -->
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>JSON-LD fixture</title>

<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Organization","name":"Fixture Ltd","url":"http://127.0.0.1:8919/jsonld.html"}
</script>

<script type="application/json">
{"@context":"https://schema.org","@type":"WebSite","name":"Fixture","url":"http://127.0.0.1:8919/jsonld.html"}
</script>

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {"@type":"ListItem","position":1,"name":"Home","item":"http://127.0.0.1:8919/"},
  ]
}
</script>

<script type="application/ld+json">
{"@context":"https://example.org/ns","@type":"Person","name":"Fixture Author"}
</script>

</head><body><h1>JSON-LD fixture</h1></body></html>
<!-- fixtures/jsonld-js.html: the block exists only after scripting runs -->
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>JSON-LD added by script</title>
<script>
  addEventListener('DOMContentLoaded', () => {
    const s = document.createElement('script');
    s.type = 'application/ld+json';
    s.textContent = JSON.stringify({ '@context': 'https://schema.org', '@type': 'FAQPage', name: 'Injected' });
    document.head.appendChild(s);
  });
</script>
</head><body><h1>JSON-LD added by script</h1></body></html>
// jsonld-check.mjs
import { readFile } from 'node:fs/promises';
const src = process.argv[2];
const html = /^https?:/.test(src) ? await (await fetch(src)).text() : await readFile(src, 'utf8');
let n = 0;
for (const m of html.matchAll(/<script([^>]*)>([\s\S]*?)<\/script>/gi)) {
  const type = (/type\s*=\s*["']?([^"'\s>]+)/i.exec(m[1]) || [])[1] || '(no type attribute)';
  if (!/json/i.test(type) && !/"@context"/.test(m[2])) continue;
  n += 1;
  if (type.toLowerCase() !== 'application/ld+json') {
    console.log(`block ${n}  type=${type}  IGNORED: not application/ld+json`);
    continue;
  }
  try {
    const o = JSON.parse(m[2]);
    const ctx = Array.isArray(o['@context']) ? o['@context'].join(' ') : o['@context'];
    const ok = /^https?:\/\/(www\.)?schema\.org\/?$/.test(String(ctx));
    console.log(`block ${n}  type=${type}  PARSED  @context=${ctx} ${ok ? '(schema.org)' : '(NOT schema.org)'}  @type=${o['@type']}`);
  } catch (e) {
    console.log(`block ${n}  type=${type}  INVALID JSON: ${e.message}`);
  }
}
console.log(`${n} candidate block(s)`);

Steps

  1. Step 1.

    Start the fixture server, then extract and parse every candidate block.

    node jsonld-check.mjs http://127.0.0.1:8919/jsonld.html
    
    block 1  type=application/ld+json  PARSED  @context=https://schema.org (schema.org)  @type=Organization
    block 2  type=application/json  IGNORED: not application/ld+json
    block 3  type=application/ld+json  INVALID JSON: Unexpected token ']', ..."919/"},
    ]
    }
    " is not valid JSON
    block 4  type=application/ld+json  PARSED  @context=https://example.org/ns (NOT schema.org)  @type=Person
    4 candidate block(s)

    Four blocks in the file, one usable block out of it. Block 2 is the one worth staring at: correct JSON, right @context, real @type, and one wrong word in the type attribute means no consumer opens it. Block 3 dies on the comma after the last array item. Block 4 parses, and its name resolves under https://example.org/ns, which no reader of schema.org knows.

  2. Step 2.

    Ask the browser the same question, in the DevTools console on the same page.

    [...document.querySelectorAll('script[type="application/ld+json"]')].map((s, i) => {
      try { const o = JSON.parse(s.textContent); return `${i + 1}: ok, @type ${o['@type']}`; }
      catch (e) { return `${i + 1}: ${e.message}`; }
    })
    
    ["1: ok, @type Organization",
    "2: Unexpected token ']', ...\"919/\"},\n  ]\n}\n\" is not valid JSON",
    "3: ok, @type Person"]

    Three entries where the file holds four blocks. The selector matches on the type attribute, so the application/json block is outside the list rather than inside it as a failure. The numbering shifts, and a block that was never seen looks exactly like a block that was never written.

  3. Step 3.

    On the page whose markup is built by script, count occurrences of the string in the served bytes.

    curl -sS http://127.0.0.1:8919/jsonld-js.html | grep -c 'application/ld+json'
    
    1

    One match, and no JSON-LD block on the page as served.

  4. Step 4.

    Parse the same URL instead of counting strings in it.

    node jsonld-check.mjs http://127.0.0.1:8919/jsonld-js.html
    
    0 candidate block(s)

    The match in step 3 is the literal 'application/ld+json' inside the inline script that builds the block later. Nothing in the response is a block.

  5. Step 5.

    Load the same URL in a browser and read the DOM after scripting.

    [...document.querySelectorAll('script[type="application/ld+json"]')].map((s) => s.textContent)
    
    ["{\"@context\":\"https://schema.org\",\"@type\":\"FAQPage\",\"name\":\"Injected\"}"]

    One block in the DOM, zero on the wire. Whether a consumer sees it depends on whether it runs scripts, which is a question about the consumer and not the markup. Record both numbers.

  6. Step 6.

    Run the extractor against a page from your own build output that carries several blocks, here this site's own static export.

    node jsonld-check.mjs out/check/check-301-vs-302-redirect/index.html
    
    block 1  type=application/ld+json  PARSED  @context=https://schema.org (schema.org)  @type=TechArticle
    block 2  type=application/ld+json  PARSED  @context=https://schema.org (schema.org)  @type=HowTo
    block 3  type=application/ld+json  PARSED  @context=https://schema.org (schema.org)  @type=FAQPage
    block 4  type=application/ld+json  PARSED  @context=https://schema.org (schema.org)  @type=BreadcrumbList
    block 5  type=application/ld+json  PARSED  @context=https://schema.org (schema.org)  @type=Organization
    block 6  type=application/ld+json  PARSED  @context=https://schema.org (schema.org)  @type=WebSite
    6 candidate block(s)

    Six separate blocks, each with its own @context, each parsed on its own. There is no limit of one block per page and no need to merge them.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | PARSED ... (schema.org) | The block is syntactically usable | Move on to the vocabulary check | | IGNORED: not application/ld+json | The content is fine and no consumer opens it | Correct the type attribute to application/ld+json | | INVALID JSON: Unexpected token | The whole block is dropped, not one field | Fix the syntax at the position named, then rerun | | (NOT schema.org) | The block parses and its terms mean something else | Set @context to https://schema.org | | 0 candidate block(s) with a non-zero grep count | The string appears in script source, not in a block | Read the DOM as well, and decide whether server rendering is required | | @type=undefined | The block parsed but names no type | Add @type, or check whether the payload sits under @graph |

Common mistakes

Sign: A grep for application/ld+json returns a count above zero on a page that serves no JSON-LD at all.Cause: The string occurs inside the inline script that creates the block in the browser, as steps 3 and 4 show on the same URL: grep says 1, the parser says 0. Counting a substring is not a check. Parse, and report the number of blocks that parsed.
Sign: The browser console lists fewer blocks than the file contains, and reports no error for the missing one.Cause: The selector in step 2 matches on the type attribute, so a block typed application/json falls outside the result set rather than landing inside it as a failure. Four blocks in the file, three entries in the console, and no message about the fourth. Count the script blocks in the source first, then compare.
Sign: One field is wrong in the markup, and the consumer behaves as though the entire block is absent.Cause: A JSON syntax error is not recoverable per field. The trailing comma in block 3 of the fixture invalidates the whole BreadcrumbList, not the last item. Expect an all-or-nothing result from any syntax defect, and check each block separately.

What to check next

FAQ

Can a page have multiple json-ld tag groups?

Yes. Step 6 shows six blocks in one head, each carrying its own @context and parsing on its own. Splitting the markup by entity keeps a syntax error inside one block instead of taking the page's whole description with it.

Does schema.org markup work if the markup is built with javascript?

It exists in the DOM and not in the response. Step 4 finds zero blocks on the wire and step 5 finds one in the DOM on the same URL. Whether a given consumer runs scripts before reading is a property of that consumer, so record both numbers.

What does a script type application/ld+json in the head do?

The browser neither executes nor renders it. The element is a container the HTML parser leaves alone, which is why a syntax error inside it produces no console message and no visible change.

Where do I look for the position of a JSON error?

JSON.parse names the token and prints the surrounding text, as in step 1. Feed each block through it separately: a whole-file parse reports the first failure and hides the rest.

Does @context have to be exactly https://schema.org?

It has to name the vocabulary whose terms you are using. Block 4 of the fixture parses with a different context and its name then resolves under that namespace instead, which is why the extractor prints the context on every line rather than assuming it.

Verified

Verified by Maks Vernynode 22.23.2curl 8.21.0Chrome 152.0.0.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic8 minpublished updated Maks Verny