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
- Node 22 for the extractor and the fixture server. No packages.
- Chrome, for the two steps that read the DOM instead of the wire. A browser figure is one capture on one machine.
- The JSON-LD 1.1 syntax specification for
@contextand@type. - A local target carrying the planted defects, printed in full below. Both files and this server produced every output on this page. Stop it by process id when you are done, never by image name.
// 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
- Step 1.
Start the fixture server, then extract and parse every candidate block.
node jsonld-check.mjs http://127.0.0.1:8919/jsonld.htmlblock 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 thetypeattribute means no consumer opens it. Block 3 dies on the comma after the last array item. Block 4 parses, and itsnameresolves underhttps://example.org/ns, which no reader of schema.org knows. - 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
typeattribute, so theapplication/jsonblock 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. - 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'1One match, and no JSON-LD block on the page as served.
- Step 4.
Parse the same URL instead of counting strings in it.
node jsonld-check.mjs http://127.0.0.1:8919/jsonld-js.html0 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. - 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.
- 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.htmlblock 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
What to check next
- How to validate structured data: once a block parses, the terms inside it still have to exist and belong.
- How to check open graph tags: the other machine-readable layer in the same head, with its own silent failure mode.
- How to check if API returns valid JSON: the same parse-and-report habit applied to a response body.
- How to check canonical tag: another head element whose defects survive a visual review.
- How to check twitter card tags: tags that read correctly and still resolve to nothing.
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.
Related on this site
basic8 minpublished updated Maks Verny