How to validate structured data
Pull every application/ld+json block out of the page and test it against the schema.org vocabulary: whether the @type exists, whether each property is defined on that type, and whether each value fits the declared range. Then compare the values against the text the page renders. Both halves run locally against a downloaded vocabulary file.
Why check this
Run this on staging sign-off and after any template change that touches product, article or breadcrumb markup. The failure it prevents is silent divergence: a pricing change lands in the template and not in the JSON-LD, so the markup keeps advertising last quarter's price while the page shows the new one.
The boundary matters. A vocabulary check decides whether the markup is well formed and whether its terms exist and are used where the vocabulary defines them. It decides nothing about whether a search engine will show a rich result. That verdict comes from one vendor's own tooling, the Rich Results Test publishes no API, and no local run stands in for it.
Prerequisites
- Node 22. No packages are needed, only
fetchandfs. - The schema.org vocabulary, downloaded once in step 1 and read from disk after that.
- A local target for the planted defects. This server and this page produced every output below.
// 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/product.html -->
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Bolt cutter 600 mm</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Bolt cutter 600 mm",
"datePublished": "2026-08-01",
"aggregateRating": 4.8,
"offers": {"@type":"Offer","price":"19.00","priceCurrency":"EUR","availability":"https://schema.org/InStock"}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BreadcrumbLists",
"itemListElement": [{"@type":"ListItem","position":1,"name":"Tools","item":"http://127.0.0.1:8919/"}]
}
</script>
</head><body>
<h1>Bolt cutter 600 mm</h1>
<p class="price">EUR 29.00</p>
<p class="stock">Out of stock, back on 2026-10-15</p>
</body></html>
// vocab-check.mjs
import { readFile } from 'node:fs/promises';
const [vocabFile, src] = process.argv.slice(2);
const graph = JSON.parse(await readFile(vocabFile, 'utf8'))['@graph'];
const bare = (x) => x['@id'].replace('schema:', '');
const list = (v) => (v === undefined ? [] : [].concat(v).map(bare));
const cls = new Map(), prop = new Map(), member = new Map();
for (const n of graph) {
const id = bare(n), t = [].concat(n['@type'] ?? []).map((x) => (typeof x === 'string' ? x : x['@id'])).join(' ');
if (t.includes('rdfs:Class')) cls.set(id, list(n['rdfs:subClassOf']));
else if (t.includes('rdf:Property')) prop.set(id, { domain: list(n['schema:domainIncludes']), range: list(n['schema:rangeIncludes']) });
else if (t.startsWith('schema:')) member.set(id, t.replace('schema:', ''));
}
const DATA = new Set(['Text', 'Number', 'Integer', 'Float', 'Boolean', 'Date', 'DateTime', 'Time', 'URL', 'DataType']);
const ancestors = (t, seen = new Set()) => {
if (!cls.has(t) || seen.has(t)) return seen;
seen.add(t);
for (const p of cls.get(t)) ancestors(p, seen);
return seen;
};
const html = /^https?:/.test(src) ? await (await fetch(src)).text() : await readFile(src, 'utf8');
const out = [];
const walk = (node, path) => {
if (Array.isArray(node)) return node.forEach((v, i) => walk(v, `${path}[${i}]`));
if (!node || typeof node !== 'object') return;
const type = node['@type'];
if (type && !cls.has(type)) out.push(`${path}: @type "${type}" is not in the vocabulary`);
for (const [k, v] of Object.entries(node)) {
if (k.startsWith('@')) continue;
const p = prop.get(k);
if (!p) { out.push(`${path}.${k}: not a schema.org property`); continue; }
if (type && cls.has(type) && p.domain.length && ![...ancestors(type)].some((a) => p.domain.includes(a))) {
out.push(`${path}.${k}: not defined on ${type} (domainIncludes ${p.domain.join(', ')})`);
}
const enumOf = typeof v === 'string' ? member.get(v.replace(/^https?:\/\/schema\.org\//, '')) : null;
const literalOk = p.range.some((r) => DATA.has(r)) || (enumOf && p.range.includes(enumOf));
if (typeof v !== 'object' && p.range.length && !literalOk) {
out.push(`${path}.${k}: literal ${JSON.stringify(v)} where rangeIncludes is ${p.range.join(', ')}`);
}
walk(v, `${path}.${k}`);
}
};
let i = 0;
for (const m of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
i += 1;
try { walk(JSON.parse(m[1]), `block${i}`); } catch (e) { out.push(`block${i}: INVALID JSON, ${e.message}`); }
}
console.log(out.length ? out.join('\n') : 'no vocabulary findings');
console.log(`${i} block(s), ${out.length} finding(s), against ${cls.size} types and ${prop.size} properties`);
// claims-check.mjs
import { readFile } from 'node:fs/promises';
const KEYS = new Set(['name', 'price', 'ratingValue', 'reviewCount', 'availability', 'headline']);
const src = process.argv[2];
const html = /^https?:/.test(src) ? await (await fetch(src)).text() : await readFile(src, 'utf8');
const visible = html
.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<head[\s\S]*?<\/head>/i, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
const claims = [];
const walk = (n, p) => {
if (Array.isArray(n)) return n.forEach((v, i) => walk(v, `${p}[${i}]`));
if (n && typeof n === 'object') return Object.entries(n).forEach(([k, v]) => walk(v, p ? `${p}.${k}` : k));
const key = p.split('.').pop().replace(/\[\d+\]$/, '');
if (KEYS.has(key)) claims.push([p, String(n)]);
};
for (const m of html.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)) {
try { walk(JSON.parse(m[1]), ''); } catch { /* syntax is the other check's job */ }
}
for (const [p, v] of claims) {
const short = v.replace(/^https?:\/\/schema\.org\//, '');
console.log(`${visible.toLowerCase().includes(short.toLowerCase()) ? 'on page ' : 'NOT ON PAGE'} ${p} = ${v}`);
}
console.log(`visible text: ${visible}`);
Steps
- Step 1.
Download the vocabulary once and keep it on disk.
curl -sS --compressed -o schemaorg.jsonld -w '%{http_code} %{size_download}\n' \ https://schema.org/version/latest/schemaorg-current-https.jsonld200 1551177Every later run reads this file. The check never needs the network again.
- Step 2.
List the predicates the vocabulary actually uses, before trusting any tool that reports a missing required property.
node -e "const g=JSON.parse(require('fs').readFileSync('schemaorg.jsonld','utf8'))['@graph']; const k=new Set(); for(const n of g) for(const x of Object.keys(n)) k.add(x); console.log([...k].sort().join(' '))"@id @type owl:disjointWith owl:equivalentClass owl:equivalentProperty rdfs:comment rdfs:label rdfs:seeAlso rdfs:subClassOf rdfs:subPropertyOf schema:contributor schema:domainIncludes schema:inverseOf schema:isPartOf schema:rangeIncludes schema:sameAs schema:source schema:supersededByNothing in that list marks a property as required.
domainIncludesandrangeIncludesare the whole constraint vocabulary. Required properties are a consumer's rule, so any tool reporting one is applying a policy it brought with it. - Step 3.
Start the fixture server and run the validator against the planted defects.
node vocab-check.mjs schemaorg.jsonld http://127.0.0.1:8919/product.htmlblock1.datePublished: not defined on Product (domainIncludes Certification, CreativeWork) block1.aggregateRating: literal 4.8 where rangeIncludes is AggregateRating block2: @type "BreadcrumbLists" is not in the vocabulary block2.itemListElement[0].item: literal "http://127.0.0.1:8919/" where rangeIncludes is Thing 2 block(s), 4 finding(s), against 1010 types and 1676 propertiesThree of those four are defects.
datePublishedbelongs toCreativeWork, notProduct.aggregateRatingtakes anAggregateRatingnode, so the bare4.8carries no rating count and no scale.BreadcrumbListsis a typo forBreadcrumbListand no consumer resolves it. The fourth line is the validator being literal: a URL string is a legal node reference in JSON-LD, and a range check cannot tell that from a wrong literal. - Step 4.
Run the same validator against a page from your own build output, here this site's own static export.
node vocab-check.mjs schemaorg.jsonld out/check/check-301-vs-302-redirect/index.htmlblock2.totalTime: literal "PT4M" where rangeIncludes is Duration block4.itemListElement[0].item: literal "https://h2check.org/" where rangeIncludes is Thing block4.itemListElement[1].item: literal "https://h2check.org/check/" where rangeIncludes is Thing block4.itemListElement[2].item: literal "https://h2check.org/clusters/performance/" where rangeIncludes is Thing block4.itemListElement[3].item: literal "https://h2check.org/check/check-301-vs-302-redirect/" where rangeIncludes is Thing 6 block(s), 5 finding(s), against 1010 types and 1676 propertiesFive findings and zero defects.
PT4Mis an ISO 8601 duration, the way aDurationis written as a literal, and the fouritemvalues are node references by URL. A check that reports only counts would call this page worse than the fixture. Read the lines. - Step 5.
Compare the values in the markup against the text the page renders.
node claims-check.mjs http://127.0.0.1:8919/product.htmlon page name = Bolt cutter 600 mm NOT ON PAGE offers.price = 19.00 NOT ON PAGE offers.availability = https://schema.org/InStock NOT ON PAGE itemListElement[0].name = Tools visible text: Bolt cutter 600 mm EUR 29.00 Out of stock, back on 2026-10-15The markup passed every syntax and vocabulary rule and still says the item costs 19.00 and is in stock, while the page shows 29.00 and out of stock. No validator built on the vocabulary alone produces this line. The breadcrumb entry is expected: navigation labels live outside the text this script reads.
- Step 6.
Find the process holding the fixture port, so you stop that one and nothing else.
netstat -ano | grep ':8919 'TCP 127.0.0.1:8919 0.0.0.0:0 LISTENING 37512The last column is the process id. Stop that id and nothing else, then run the same command again. It prints nothing when the port is free.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| @type "X" is not in the vocabulary | A typo or an invented type. No consumer resolves it | Fix the spelling against the vocabulary file |
| not defined on T (domainIncludes ...) | The property exists but belongs to other types | Move it to the node that owns it, or change the node's type |
| literal "v" where rangeIncludes is N | Either a missing nested node, or a URL reference, or an ISO literal | Read the value. Only a plain string where a node is meant is a defect |
| INVALID JSON | The block is dropped whole before any of this runs | Fix the syntax first, then rerun |
| NOT ON PAGE for a price, rating or availability | The markup and the rendered page disagree | Fix the source that feeds the markup. This is a data defect, not a markup one |
| No findings at all | The terms are well formed and used where defined | Nothing here says a rich result will appear |
Common mistakes
What to check next
- How to check json ld: the syntax layer under this one, where a block can be dropped before the vocabulary is consulted.
- How to check open graph tags: the other machine-readable layer in the same document head.
- How to check canonical tag: structured data that names a URL should name the same one the canonical does.
- How to check if API returns valid JSON: the same parse-before-you-trust habit applied to an API response.
- How to check if a page is noindex: markup on a page that no crawler is allowed to index buys nothing.
FAQ
How to check schema markup of a website?
Fetch the page, extract the application/ld+json blocks, and run them against the vocabulary file as in step 3. For markup built in the browser, read the blocks from the DOM instead, as the JSON-LD syntax procedure shows.
Is there a tool for testing schema.org markup?
Several, and they disagree, because each encodes the requirements of the consumer it was built for. The vocabulary file is the one source they share. Validate against it first, then run whichever vendor tool matters for your release.
How to check if a page has schema markup?
Count the blocks: curl -sS <url> | grep -c 'application/ld+json'. That counts string matches, not parsed blocks, so treat a non-zero result as a reason to run the validator rather than as an answer.
Does this tell me whether a rich result will show?
No. Each search engine decides eligibility against its own documented requirements, and the Rich Results Test publishes no API. This procedure gates the part you control: correct, well formed markup that matches the page.
Which findings should block a release?
Unknown types, properties used outside their domain, invalid JSON, and any line where the markup and the visible page disagree. Range findings on URL references and ISO literals are noise from the check itself.
Verified
Verified by Maks Vernynode 22.23.2curl 8.21.0schema.org vocabulary 2026-09-11 snapshot
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
intermediate12 minpublished updated Maks Verny