Sign inCreate a free account
Theme

Structured data for developers: generating JSON-LD that actually validates

Hand-written JSON-LD breaks in quiet ways: a stray quote, a missing field, a date in the wrong format. Build it from data instead, and test it like any other output.

Structured data is one of those things that gets added to a site once, by hand, and then slowly drifts out of date. A price changes on the page but not in the JSON-LD. A title gets a quotation mark in it and breaks the whole block. Nobody notices, because nothing on the page looks wrong.

If you treat structured data as code rather than copy, most of these problems go away. Here's how we approach it.

Never build JSON-LD by concatenating strings

This is the most common source of broken structured data I see:

HTML
<script type="application/ld+json">
{
  "@type": "Article",
  "headline": "<?= $post->title ?>"
}
</script>

It works until a title contains a double quote, or a backslash, or a line break. Then the JSON is invalid and search engines ignore the whole block.

Build a normal data structure and let your language's JSON encoder handle escaping:

Code
$schema = [
    '@context' => 'https://schema.org',
    '@type' => 'BlogPosting',
    'headline' => $post->title,
    'datePublished' => $post->publishedAt->format(DATE_ATOM),
    'author' => ['@type' => 'Person', 'name' => $post->author],
];

echo '<script type="application/ld+json">'
    . json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG)
    . '</script>';

Note JSON_HEX_TAG (or your language's equivalent). It escapes < and >, so a value containing </script> can't close the tag early. That's a correctness fix and a security fix at once.

Generate it from the same data as the page

The price in your JSON-LD should come from the same variable as the price on the page. The title should be the same title. If they're generated from one source, they can't disagree.

This matters more than it sounds. Google's guidelines say structured data must describe what's visible on the page, and markup that doesn't match can cost you rich results across a whole site.

Use the right formats

  • Dates in ISO 8601: 2026-09-30T09:30:00Z, not "30 Sep 2026".
  • URLs absolute: https://webrankstats.com/image.png, not /image.png.
  • Prices as numbers with a separate currency: "price": "49.00", "priceCurrency": "USD".

Render it on the server

JSON-LD added by a tag manager or client-side script only exists after JavaScript runs. Google can usually handle that, but plenty of other consumers can't. If it matters, put it in the HTML your server sends.

Test it like any other output

Structured data is output, so it can have tests. A simple one that pays for itself: render a page with awkward data (quotes, an ampersand, a </script> in the title) and assert that the JSON-LD still parses.

Code
$html = render_post(['title' => 'He said "hi" </script> & left']);
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $html, $m);
assert(json_decode($m[1]) !== null);

Then use Google's Rich Results Test or the Schema.org validator to check that the types and fields are what you expect.

Which types are worth it

Not every schema type earns something visible in search anymore. The ones still worth implementing for most sites: Organization, BreadcrumbList, Article or BlogPosting for content, and Product with offers for shops.

WebRankPage reports read the structured data on a page, check that it parses, and flag types that are missing properties they need. It's a quick way to confirm your generated markup reached production intact.

Free forever · no card

Run it on a page you care about.
See what it says.

Nothing is withheld on the free tier. An account adds the part a single report cannot give you: a record of whether anything you changed actually worked.

  • Every report you run, kept
  • Compare a page over time
  • Three reports a day, not one
  • Every check, same as the paid tiers

Create free account

Free forever · no card required

Already registered? Sign in

  • TLS encrypted
  • Instant setup
  • No card needed