Structured Data for Developers
You added your first JSON-LD block in HTML for SEO & Social Sharing. On a real project the job is bigger: dozens of page types, data that comes from a database, several related things on one page, and markup that must stay correct after every release. This lesson covers that developer side of structured data, the machine-readable description of the facts on a page.
You will pick schema.org types for common pages, connect several items with @id and @graph, generate JSON-LD from server data without opening a security hole, and check it automatically. If you want the marketing view (which rich results exist and how they appear), read Structured Data with JSON-LD in the Digital Marketing course.
Pick the type that matches the page
schema.org is a shared vocabulary of types (such as Product or Event) and their properties (such as name or startDate). Start from what the page is mainly about:
| Page | Main type | Often added |
|---|---|---|
| Home page of a business | Organization, or a LocalBusiness subtype such as Bakery | WebSite |
| Product page | Product with an Offer | BreadcrumbList |
| Blog post or news story | BlogPosting or Article | BreadcrumbList, Person for the author |
| Event page | Event | Place, Organization |
| Category or list page | Usually just BreadcrumbList |
Search engines show special results only for some types and some properties. Google lists the ones it supports in its search gallery, with required and recommended properties for each. That list changes: Google stopped showing FAQ rich results in 2026, for example. Check the gallery when you plan a feature, and do not add markup just because a blog post from a few years ago said it helps.
Build a product page's markup
For a product snippet, Google requires a name plus at least one of offers, review or aggregateRating. An Offer needs a price; priceCurrency (a three-letter ISO 4217 code such as INR) and availability are recommended. Write prices as plain numbers, 1299 or 1299.00, without the rupee sign or commas.
A page usually describes more than one thing: the product, the shop selling it and the breadcrumb trail. You can put them in one @graph array and connect them with @id values. An @id is just a unique name for a thing, written as a URL with a # part so it never clashes with a real page. Any other item can then point to it with { "@id": "..." } instead of repeating its details.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Brass Diya Set of 4 | Meera Handicrafts</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://www.example.com/#org",
"name": "Meera Handicrafts",
"url": "https://www.example.com/",
"logo": "https://www.example.com/img/logo.png"
},
{
"@type": "Product",
"@id": "https://www.example.com/diyas/brass-set-4#product",
"name": "Brass Diya Set of 4",
"image": "https://www.example.com/img/brass-diya-set.jpg",
"description": "Four hand-polished brass diyas, 7 cm across, made in Moradabad.",
"sku": "MH-DIYA-04",
"brand": { "@id": "https://www.example.com/#org" },
"offers": {
"@type": "Offer",
"price": 1299,
"priceCurrency": "INR",
"availability": "https://schema.org/InStock",
"seller": { "@id": "https://www.example.com/#org" }
}
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://www.example.com/" },
{ "@type": "ListItem", "position": 2, "name": "Diyas", "item": "https://www.example.com/diyas/" },
{ "@type": "ListItem", "position": 3, "name": "Brass Diya Set of 4" }
]
}
]
}
</script>
<style>body { font-family: system-ui, sans-serif; margin: 16px; } li { margin: 4px 0; }</style>
</head>
<body>
<h1>Brass Diya Set of 4</h1>
<p>₹1,299. In stock. Four hand-polished brass diyas from Moradabad.</p>
<h2>Items a crawler finds</h2>
<ul id="items"></ul>
<script>
const data = JSON.parse(document.querySelector('script[type="application/ld+json"]').textContent);
const byId = new Map(data["@graph"].filter((x) => x["@id"]).map((x) => [x["@id"], x]));
for (const item of data["@graph"]) {
let text = item["@type"] + (item.name ? ": " + item.name : "");
if (item.brand) text += " (brand resolves to " + byId.get(item.brand["@id"]).name + ")";
if (item.offers) text += ", " + item.offers.priceCurrency + " " + item.offers.price;
if (item.itemListElement) text += ": " + item.itemListElement.map((x) => x.name).join(" > ");
const li = document.createElement("li");
li.textContent = text;
document.querySelector("#items").append(li);
}
</script>
</body>
</html>The script plays the part of a crawler: it parses the block and follows the brand reference back to the organisation. Notice that the last breadcrumb has no item URL. Google then uses the address of the current page, so you do not need to repeat it.
Every fact in the markup also appears on the page: the name, the price and the stock status. That is the most important rule of structured data.
Rules that keep your markup trusted
Google's guidelines are strict, and breaking them can lead to a manual action, a penalty applied by a reviewer that removes rich result eligibility for the site.
- Mark up only what users can see on the page. A price in JSON-LD must match the visible price, including during a sale.
- Never invent ratings or reviews. A shop or company page cannot mark up reviews it collected about itself to get star ratings for itself in results.
- Keep the page crawlable. Markup on a page blocked by
robots.txtor markednoindexis never used. - Use the type that matches the page's main purpose. A recipe page needs
Recipemarkup even if it also has a video. - Write dates in ISO 8601 with India's time zone, for example
2026-12-12T18:30:00+05:30.
Valid markup makes a page eligible for a rich result. It never guarantees one.
Generate JSON-LD safely from data
On a real site the values come from a database. Two things go wrong when developers build JSON-LD by gluing strings together. A quote or backslash in a product name produces invalid JSON, so the whole block is ignored. Worse, a value containing </script> closes the script element early, and whatever follows is read as HTML. If that value came from a user, an attacker can run their own script on your page.
The fix is simple: build an array or object in your language, then let a JSON encoder turn it into text, and make sure < is escaped. In PHP, json_encode() with the JSON_HEX_TAG flag turns < and > into \u003C and \u003E, which JSON readers turn back into the same characters.
<?php
declare(strict_types=1);
$product = [
'name' => 'Brass Diya Set </script><script>alert(1)</script>',
'price' => 1299,
];
$data = [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $product['name'],
'offers' => [
'@type' => 'Offer',
'price' => $product['price'],
'priceCurrency' => 'INR',
],
];
$json = json_encode(
$data,
JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
);
echo '<script type="application/ld+json">' . $json . '</script>', "\n";<script type="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"Brass Diya Set \u003C/script\u003E\u003Cscript\u003Ealert(1)\u003C/script\u003E","offers":{"@type":"Offer","price":1299,"priceCurrency":"INR"}}</script>The hostile name is now harmless text inside the JSON. Do not run htmlspecialchars() on JSON-LD: the content of a script element is not decoded as HTML, so " would appear literally in the data. The example below shows the same problem and fix in JavaScript, for pages that build markup in the browser or in Node.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Safe JSON-LD Output</title>
<style>
body { font-family: system-ui, sans-serif; margin: 16px; }
pre { background: #f4f4f4; padding: 8px; white-space: pre-wrap; }
</style>
</head>
<body>
<h1>Template output, as the browser parses it</h1>
<div id="out"></div>
<script>
const review = { "@type": "Product", name: 'Diya <\/script><p>Injected!</p>' };
const unsafe = '<script type="application/ld+json">' + JSON.stringify(review) + "<\/script>";
const safe = '<script type="application/ld+json">' +
JSON.stringify(review).replaceAll("<", "\\u003c") + "<\/script>";
for (const [label, html] of [["Unsafe", unsafe], ["Safe", safe]]) {
const doc = new DOMParser().parseFromString(html, "text/html");
const block = doc.querySelector("script").textContent;
let result;
try { result = "JSON OK, name = " + JSON.parse(block).name; }
catch { result = "JSON BROKEN: " + block; }
const extra = doc.querySelector("p");
const pre = document.createElement("pre");
pre.textContent = label + "\n" + result + (extra ? "\nExtra HTML element created: <p>" + extra.textContent + "</p>" : "");
document.querySelector("#out").append(pre);
}
</script>
</body>
</html>In the unsafe version, the JSON is cut off and a real paragraph element appears in the page. In the safe version, the name comes back exactly as stored, angle brackets and all.
Test markup before and after release
Use three layers of checks:
- While developing, paste the page or its URL into Google's Rich Results Test to see which features the page is eligible for, and the Schema Markup Validator for general schema.org errors.
- In your test suite, render a sample page of each type, parse its JSON-LD blocks and assert the facts you care about, such as "price matches the database" and "
priceCurrencyisINR". A JSON parse failure should fail the build. Linting & Automated HTML Checks shows how such checks fit into a project. - After release, Google Search Console shows reports for each rich result type with errors per page. Watch them after template changes.
Common mistakes
- Building JSON by gluing strings. Use a JSON encoder; one stray quote breaks the block.
- Not escaping
<inside JSON-LD. A value containing</script>closes the block and opens an XSS hole. - Prices with symbols or commas. Write
1299, not₹1,299, and givepriceCurrencyseparately. - Markup that disagrees with the page. A sale price on the page with the old price in JSON-LD misleads users.
- Repeating the organisation's details in every item. Give it an
@idonce and reference it. - Copying old advice. Check Google's search gallery; supported features change.
Key points
- Choose the schema.org type that matches the page's main purpose, then add helpers such as
BreadcrumbList. @graphholds several items in one block, and@idlets items refer to each other.- Product markup needs a
nameand anoffers,revieworaggregateRating; prices are plain numbers with an ISO currency code. - Every marked-up fact must be visible and true on the page; fake or self-serving reviews break the guidelines.
- Generate JSON-LD with a JSON encoder and escape
<; never paste raw strings into a script block. - Test with the Rich Results Test, automate checks in your test suite, and watch Search Console after releases.
Exercise
Write the JSON-LD for a blog post page on "Isha Bakes" titled "Eggless Rava Cake in a Pressure Cooker", published on 5 November 2026 at 9 am India time by Isha Verma. Use a @graph with an Organization (with an @id), a BlogPosting that names Isha as author and the organisation as publisher by reference, and a three-level BreadcrumbList (Home, Recipes, the post). The post URL is https://www.example.com/recipes/eggless-rava-cake.
Show solution
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://www.example.com/#org",
"name": "Isha Bakes",
"url": "https://www.example.com/"
},
{
"@type": "BlogPosting",
"headline": "Eggless Rava Cake in a Pressure Cooker",
"datePublished": "2026-11-05T09:00:00+05:30",
"author": { "@type": "Person", "name": "Isha Verma" },
"publisher": { "@id": "https://www.example.com/#org" },
"mainEntityOfPage": "https://www.example.com/recipes/eggless-rava-cake"
},
{
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://www.example.com/" },
{ "@type": "ListItem", "position": 2, "name": "Recipes", "item": "https://www.example.com/recipes/" },
{ "@type": "ListItem", "position": 3, "name": "Eggless Rava Cake in a Pressure Cooker" }
]
}
]
}The publisher points to the organisation through its @id, so its details are written once. The date carries the +05:30 offset, and the last breadcrumb leaves out item because it is the current page. Wrap it in <script type="application/ld+json"> on the page.
Quick Quiz
1. What does an @id such as https://www.example.com/#org do?
Other items can point to it with { "@id": "..." } instead of repeating its details.
2. How should a price of one thousand two hundred ninety-nine rupees appear in an Offer?
Prices are plain numbers, with the currency given as a three-letter ISO 4217 code.
3. Why should you escape < when printing JSON-LD from server data?
JSON allows <, but HTML parsing ends the script element at </script>, so escaping keeps the data inside.
4. A page's JSON-LD passes the Rich Results Test. What is true?
Valid markup only makes a feature possible; it is never guaranteed.
Learn HTML with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
