Check B1 is worth five points and has no partial credit for effort. It finds every <script type="application/ld+json"> in the raw HTML of a page, hands the contents of each one to a JSON parser, and counts the page only if at least one block came back and none failed. A block that is nearly valid scores exactly the same as no block at all, and the rubric says so in as many words: invalid JSON scores zero for that page.
This post covers the five ways a site that has done the work still ends up with a zero, how B1 counts pages so you know which pages matter, and how to test your own output the way the scanner does.
How B1 counts pages
The scan fetches the homepage and up to five more pages, preferring pricing, product, documentation, about, contact and cart routes when it can find them. Any page that did not return a 200 with a body is dropped before B1 looks at anything.
Three of the five points depend on the homepage alone. It earns them if it carries at least one ld+json block and every block on it parses. The remaining two points come from coverage: if half or more of the crawled pages carry valid structured data, the homepage included, you get them. Six pages crawled means three need to pass. Two pages crawled means both do.
The definition of "valid" is strict in one way that surprises people. A page with one good block and one broken block counts as a page with invalid JSON-LD, not as a page with valid JSON-LD. The two are recorded separately in the evidence as pagesWithValidJsonLd and pagesWithInvalidJsonLd, alongside pagesCrawled and homepageHasValidJsonLd, so the report tells you which failure you have. A template that emits a good Organization block and a broken Product block on every product page has a coverage of zero.
The five mistakes
1. A trailing comma
The most common one, and the easiest to produce from a template that loops over items and appends a comma after each. JavaScript object literals tolerate it. JSON does not.
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Example Ltd",
"url": "https://example.com/",
}
The comma after the last value makes the whole block unparseable. Remove it and the block is fine. The reliable fix is not to write JSON by hand at all: build an object and serialise it with the language's JSON encoder, which cannot emit a trailing comma.
2. HTML-escaped quotes
Most templating engines escape output by default, because the alternative is cross-site scripting. Put a JSON string through the ordinary output path and every " becomes ", every & becomes &.
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"WebSite"}
</script>
The parser takes the raw text between the script tags and passes it straight to JSON.parse. It does not decode entities first, and neither does a browser, because entity decoding does not apply inside a script element. The block above fails on its first character. The right output uses the template engine's unescaped output form for that one value, which is safe here precisely because the value is produced by your own serialiser rather than by user input.
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"WebSite","name":"Example"}
</script>
If a string inside the JSON needs to contain </script>, escape the forward slash as <\/script> in the JSON, which is legal JSON and does not terminate the element.
3. An empty graph
A @graph with nothing in it is valid JSON, so this is the one mistake on the list that does not fail B1 itself. It fails everything downstream. Check B2 collects every @type it can find across the crawled pages and finds none, which costs all five of its points. The site-type detection that decides which types B2 wants also reads the homepage schema and sees nothing.
{ "@context": "https://schema.org", "@graph": [] }
This usually comes from a plugin or a component that is installed but not configured, or from a builder that filters out nodes with missing fields and silently drops all of them. Look at what the block actually contains, not whether it exists.
4. The wrong script type
The selector is exact. The scanner, like everything else that consumes JSON-LD, looks for script[type="application/ld+json"] and nothing else. A block with type="application/json", type="text/json", type="ld+json" or no type attribute is not found, and a page whose only structured data is in such a block is scored as having none.
<script type="application/json">{"@type":"Organization"}</script>
The fix is the attribute value, verbatim. The media type is registered by the JSON-LD specification and there is no accepted variant.
5. Injecting it client-side
Everything above assumes the block is in the HTML the server sends. A great deal of structured data is not. It is added by a tag manager, by a component that runs after mount, or by a helper library that writes into document.head once the page has hydrated. In a browser's element inspector it looks identical to a server-rendered block. In the response body, it does not exist.
The scanner parses the raw response body, which is what an agent using a plain fetch receives. The two fetches explains the gap in general; for B1 the consequence is total. If your JSON-LD is produced by JavaScript, B1 reads a page with no JSON-LD on it. So does any agent that does not run your scripts, and most do not.
// Scores zero: the block exists only after this runs in a browser.
useEffect(() => {
const s = document.createElement("script");
s.type = "application/ld+json";
s.textContent = JSON.stringify(schema);
document.head.appendChild(s);
}, []);
Move the block into the server-rendered output. On a framework with server components this is a matter of rendering the script element in the page or layout rather than in an effect. On a CMS it usually means using the theme's head template rather than a tag manager.
How we emit ours
Our site emits one block per page, built by typed functions that return plain objects, wrapped in a single @graph and serialised with JSON.stringify, then rendered on the server as part of the page. There is no hand-written JSON anywhere and no client-side path that could add or alter a block. That is not sophistication; it is the cheapest way to make four of the five mistakes above impossible. Our own report at /site/agentfriendlyrank.com shows the result.
Test it the way the scanner does
You do not need a validator service. Fetch a page without a browser, pull out every ld+json block, and try to parse each one.
curl -s https://example.com/ | node -e '
const html = require("fs").readFileSync(0, "utf8");
const re = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
let n = 0;
for (const m of html.matchAll(re)) {
n += 1;
try { JSON.parse(m[1]); console.log(`block ${n}: ok`); }
catch (e) { console.log(`block ${n}: FAIL ${e.message}`); }
}
if (n === 0) console.log("no ld+json blocks in the raw HTML");
'
Three outcomes map directly onto the five mistakes. "No blocks" means either the wrong script type or client-side injection. "FAIL" with a position near the end is usually a trailing comma; at position 0 or 1 it is usually escaped quotes. "ok" on every block but a low B2 score means the blocks parse and say nothing, which is the empty graph.
Run it against the homepage and against two or three interior pages from different templates. B1 wants coverage as well as a good homepage, and the interior templates are where a broken loop or an escaped value tends to live. If the fix is a template-level change you would rather have done for you, that is the middle tier of the implementation service.
Check your own site
The free scan runs exactly this parse on up to six pages and reports the four evidence counts above, so you can see whether the problem is the homepage, coverage, or a block that does not parse. The definition of B1 and the rest of the understanding pillar is on the methodology page.