Skip to content
pageinspection

Broken JSON-LD is invisible JSON-LD, and nothing tells you

One stray character and a parser stops reading, discards the block and moves on without reporting anything. The page looks identical, the markup is still in your source, and every tool you would think to check says nothing is wrong. This is the failure that survives longest, because success and failure look the same from the outside.

The short answer

A JSON-LD block with a syntax error is discarded silently. Nothing in a normal stack watches for it: the page renders, no console error appears, no build fails, and the structured data has simply stopped existing. The usual causes are unescaped quotes in a templated string, a trailing comma, and HTML entity escaping applied to JSON.

CriticalAudit check · Schema syntax validation

Nothing in your stack is watching this fail

Work through the layers that would normally tell you. The browser never parses the block: a ld+json element is inert data, so there is no syntax error in the console, no failed request in the network panel and no visual change on the page. Your test suite renders the template successfully, because the template did render successfully. Your build passes, because a string containing invalid JSON is a perfectly valid string.

Search Console will not tell you either, and the reason is worth understanding rather than assuming. Its enhancement reports are keyed to types, and a block that fails to parse has no type. There is nothing for the report to file it under, so it is not listed as broken. It is absent. On top of that, only types tied to a live feature get a report at all, which the HowTo guide covers in detail.

The only signal a failed parse produces is silence, and silence is also what a correct implementation produces.

Six ways a template breaks its own JSON-LD

Hand-written JSON-LD rarely breaks, because you look at it. Generated JSON-LD breaks constantly, and almost always in one of these ways.

  • A trailing comma. Legal in a JavaScript object literal, illegal in JSON, and the mental model everyone brings to writing braces is the JavaScript one. A conditional block that emits a property with a comma and then emits nothing after it produces this every time.
  • An unescaped double quote from a value. The most common cause by a wide margin, and the one that arrives from content rather than code. A product called 13" frame, a headline with a quoted phrase in it, an author who signs with a nickname in quotes. Each one closes the JSON string early and everything after it is garbage.
  • HTML entities where JSON wants characters. A field arrives as Costs & savings and the block now contains a literal entity, not an ampersand. Where this turns fatal is when the escaping hits a structural character and the quotes around a value become ", at which point there are no string delimiters left.
  • A </script> sequence inside a value. Valid JSON, lethal HTML. The parser ends the element at the first </script it sees, wherever that is, so the remainder of your JSON becomes text in the page and the block is truncated mid-object. A tutorial about script tags breaks its own markup by describing it.
  • JSON assembled by concatenation. Building the block by adding strings together means every comma, brace and quote is your responsibility on every code path. Any value that turns out to be null, undefined or a number formatted for display puts an unquoted token into the output.
  • The element rewritten in transit. Minifiers, optimisation plugins and tag managers all touch script tags. One that reorders, inlines or re-emits your block can lose or alter the type attribute, and the exact attribute value is what makes anything look inside. Test the deployed page, not the local one.

Three of those, in one real block that shipped:

broken.html
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": "Why we left the "enterprise" plan",
  "description": "Costs &amp; what we cut",
  "author": { "@type": "Person", "name": "R. Iyer" },
  "datePublished": "2026-02-11",
}
</script>

The quotes around enterprise came from a CMS title field. The &amp; came from a template that HTML-escapes every interpolation. The comma after the date came from a conditional image property that was not emitted because the post had no image. Any one of them alone discards the whole block.

The two escaping systems that do not compose

Here is the mechanism behind half of that list, and it is specific to this element. In HTML, <script> is a raw text element. Character references are not decoded inside it. Writing &quot; in a script block does not produce a quote mark; it produces six literal characters that a JSON parser reads as six literal characters.

Which means the safety feature in your template engine is the thing breaking your markup. Jinja, Twig, Blade and Handlebars all HTML-escape interpolated values by default. That default is correct everywhere else on the page and wrong here, because it applies HTML escaping to a context where HTML escaping is not decoded.

The obvious response is to turn it off for this block, with |safe, {!! !!} or a triple brace. That removes the only thing standing between a content field and a </script> sequence ending your element early. Neither setting is correct, because neither of them is JSON escaping.

Escaped or unescaped, an interpolated string is the wrong tool.

A value inside JSON-LD has to be escaped twice by two different rules: JSON's rules, so quotes and newlines survive, and one HTML-specific rule, so no tag-closing sequence reaches the parser. No template engine setting does both, because no template engine knows it is writing JSON.

Finding the templates that emit a bad block

The reason this is not a single-page question in practice: a parse failure triggered by a content value affects every page whose data contains that value, and no page that does not. One product with an inch mark in its name breaks one product page out of four hundred, and checking your own homepage will never find it.

This one needs the full crawl

Telling a clean parse from a failed one means reading every JSON-LD block on the page and attempting to parse each, which the page-level audit does rather than any single field being able to report.

The instant search on this site audits a single page, so rather than show you a verdict it cannot support, this guide sends you to the place the check actually runs.

Build an object, serialise it once

The fix that generalises is a rule about where JSON gets created: never in a template. Build a plain object in code, hand it to a serialiser, and print the result. A serialiser already knows how to escape a quote, a newline and a control character, and it cannot forget a comma, because it is not writing commas by hand.

One HTML-specific step remains after serialising. Replace every < with the JSON escape \u003c, which parses back to the same character and contains nothing the HTML parser will read as the start of a tag. That single substitution removes the truncation class of failure permanently.

lib/json-ld.ts
/**
 * Serialise a JSON-LD object for embedding in HTML.
 *
 * Escaping `<` as \u003c is a valid JSON escape that parses back to the
 * same character, so a value containing "</script>" survives as text
 * instead of ending the script element and truncating the block.
 */
export function jsonLdHtml(data: unknown): string {
  return JSON.stringify(data).replace(/</g, '\\u003c')
}

Then build the object from your data, and omit properties rather than emitting empty ones. A property whose value is absent should not be in the object at all: an empty string is a claim that the value is empty, which is a different statement from having nothing to say.

app/blog/[slug]/page.tsx
const articleLd = {
  '@context': 'https://schema.org',
  '@type': 'Article',
  headline: post.title,
  datePublished: post.publishedAt.toISOString(),
  dateModified: post.updatedAt.toISOString(),
  author: { '@type': 'Person', name: post.author.name },
  ...(post.heroImage ? { image: post.heroImage.absoluteUrl } : {}),
}

// ...

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: jsonLdHtml(articleLd) }}
/>

Every language has the equivalent. In PHP, json_encode($data, JSON_HEX_TAG) does the serialising and the angle-bracket escaping in one call. In Python, json.dumps(data) followed by the same replacement. The language matters less than the rule: one object, one serialiser, zero hand-written punctuation.

Why an unreadable block is worse than no block

Severity here is not about the value of the markup. It is about the gap between what you believe you shipped and what is actually being read. Every other Critical failure on the catalogue is visible to somebody: a broken link 404s, a redirect chain shows up in a waterfall, a noindex tag is right there in the source. This one reports success at every layer a person would think to look at, so the belief persists for as long as nobody specifically tests it. Teams have carried this for years.

The second half of the argument is blast radius, and it is the reason this outranks simply having no markup. JSON-LD comes out of a template, so the fault is never one page. It is every page of that type. And because the trigger is usually a data value rather than a code change, it appears months after the deploy that was tested, the first time an editor types a quotation mark into a title field. Nothing in your pipeline runs at that moment. The scale is defined on the audit page, and this sits at the top of it because the cost is not the missing markup, it is the confidence that the markup is fine.

Confirming every block on the page parses

Do not test this by eye and do not test one page. Fetch the HTML, extract every ld+json element and attempt to parse each one. Anything that throws is a block you do not have. Thirty lines of Node, no dependencies, and it exits non-zero so it can sit in CI.

scripts/check-json-ld.mjs
const url = process.argv[2]
const html = await fetch(url).then((r) => r.text())

const blocks = [
  ...html.matchAll(
    /<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/g,
  ),
]

if (!blocks.length) {
  console.error(`${url}: no JSON-LD found`)
  process.exit(1)
}

blocks.forEach(([, body], i) => {
  try {
    const parsed = JSON.parse(body)
    const type = parsed['@type'] ?? parsed['@graph']?.map((n) => n['@type'])
    console.log(`block ${i + 1}: parses, @type ${JSON.stringify(type)}`)
  } catch (err) {
    console.error(`block ${i + 1}: ${err.message}`)
    process.exitCode = 1
  }
})

Run it against the deployed URL rather than a local build, so anything your host or your optimisation layer does to the markup is included in the test. Then point it at the pages most likely to break: the longest product name you sell, the post with a quoted phrase in its title, anything with an apostrophe or a measurement in it. Those are the rows that fail, and they are never the homepage.

For a one-off check by hand, validator.schema.org reports a parse error with a position and does not filter by feature support. Once it parses, you have cleared this rung and moved on to the next one, where a block that parses and still earns nothing is a different problem with an identical appearance.

Questions this check raises

How do I know if my JSON-LD has a syntax error?
You will not find out from the page, which renders normally. Fetch the HTML, extract the script contents and run it through a JSON parser, or paste it into the Rich Results Test. The failure is silent by design: an invalid block is treated as absent.
What causes JSON-LD to break most often?
Templating a value that contains a quotation mark or an apostrophe without escaping it, which ends the string early and invalidates the rest of the block. Close behind are trailing commas from a loop and HTML entity escaping applied to the JSON, which turns quotes into &quot; and makes the whole block unparseable.
How do I stop it happening again?
Build a real object in code and serialise it once with a JSON serialiser, instead of assembling the JSON as a string in a template. That makes escaping the serialiser problem rather than yours, and it is the single change that eliminates the entire class of failure.