This guide is for the code-curious who would rather write structured data than click through a generator. It assumes you are comfortable with HTML and a little JavaScript. We will cover the anatomy of a JSON-LD block, how to connect everything into a single @graph, the geo and openingHoursSpecification blocks validators keep asking for, fixing common errors with find-and-replace, generating markup dynamically from your data, and validating every page in CI. For the strategy behind all of this, see our advanced schema and schema mastery guides.
Schema markup is just a JSON object describing the page, dropped into a script tag in the <head>. Every block has an @context (always Schema.org), an @type, and the properties that type supports:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": "How to ship JSON-LD by hand",
"datePublished": "2026-06-01",
"dateModified": "2026-06-01",
"author": { "@type": "Person", "name": "Jane Dev" }
}
</script>
That is the whole idea. Pick the right @type, fill the properties from the page’s real content, and put it in the head. Read one block and you can write any of the 800-plus types — the structure never changes, only the type and its properties.
Shipping separate blocks per entity is the beginner mistake. Use a single @graph array where each node has an @id and other nodes reference it by that id, so you define each entity once and express how they relate:
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://example.com/#org",
"name": "Example Ltd",
"url": "https://example.com/",
"sameAs": [
"https://x.com/example",
"https://www.linkedin.com/company/example"
]
},
{
"@type": "WebPage",
"@id": "https://example.com/post#webpage",
"url": "https://example.com/post",
"name": "Post title",
"isPartOf": { "@id": "https://example.com/#org" },
"author": { "@id": "https://example.com/#org" }
}
]
}
</script>
Now the WebPage’s author and isPartOf point at one canonical Organization node rather than repeating it. No contradictions, less markup, and Google gets one coherent model instead of fragments to reconcile.
If you run a LocalBusiness through a validator and see warnings for geo and openingHoursSpecification, those are the two properties to add. They are plain sub-objects — coordinates and a list of day/time ranges:
{
"@type": "LocalBusiness",
"name": "Example Ltd",
"geo": {
"@type": "GeoCoordinates",
"latitude": 53.235,
"longitude": -1.421
},
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
"opens": "09:00",
"closes": "17:00"
}
]
}
Use your real coordinates and real hours — both are factual claims about the business, so never invent them. One rule that overrides everything in this book: only mark up genuine, visible data. Fabricating an aggregateRating or fake reviews to clear a warning is exactly what triggers a manual action, so leave optional properties out rather than faking them.
Most schema fixes are a one-property edit repeated across many files — perfect for find-and-replace. Say your WebPage block is missing author. The change is to append one property after the last existing one. Before:
After:
"dateModified": "2026-06-01",
"author": { "@id": "https://example.com/#org" }
}
Across a directory of static files, that is a single anchored substitution. Always back up first and match a unique string so you cannot hit the wrong line:
sed -i.bak \
's|"dateModified": "2026-06-01"|"dateModified": "2026-06-01",\n "author": {"@id":"https://example.com/#org"}|' \
*.html
The .bak suffix writes a backup of every file so you can roll back with for f in *.bak; do mv "$f" "${f%.bak}"; done. Anchor on a string that appears exactly once per file; if it is not unique, narrow it until it is.
Hand-write a few blocks; template the rest from your data so every page of a type emits correct markup automatically. A minimal generator is just a function returning a stringified object:
const ORG_ID = "https://example.com/#org";
function articleSchema(post) {
const data = {
"@context": "https://schema.org",
"@type": "Article",
"headline": post.title,
"datePublished": post.published,
"dateModified": post.updated,
"author": { "@id": ORG_ID }
};
// only include optional fields when real data exists
if (post.ratingCount > 0) {
data.aggregateRating = {
"@type": "AggregateRating",
"ratingValue": post.ratingValue,
"ratingCount": post.ratingCount
};
}
return `<script type="application/ld+json">${JSON.stringify(data)}</script>`;
}
Two things make templated schema safe: populate every field from the page’s real data so markup and content can never drift, and guard optional fields (like aggregateRating) behind a check so a record with no reviews emits no rating rather than an empty or fake one. The win is that the integrity rule scales — but so does any mistake, which is why validation matters.
Because templated schema is code, a broken block is a production bug replicated across every page. Catch it before it ships. The cheapest gate just extracts each JSON-LD block and parses it — invalid JSON throws and fails the build:
node -e '
const fs = require("fs");
const html = fs.readFileSync(process.argv[1], "utf8");
const re = /<script type="application\/ld\+json">([\s\S]*?)<\/script>/g;
let m, n = 0;
while ((m = re.exec(html))) { JSON.parse(m[1]); n++; }
console.log("ok:", n, "blocks");
' page.html
Wire that across your built pages in CI, and for deeper checks run representative pages — including edge cases like a product with no reviews — through the Schema Debugger when you change a template. Generate starting blocks fast with the AI Schema Generator and build connected structures with the Schema Builder, then own the markup in code.
A coder inherits a blog where every post has a standalone Article block and a separate Organization block, duplicated and slightly inconsistent on every page, and the validator flags missing authorship. They refactor to a single @graph: Organization defined once with a stable @id, each Article referencing it as author and publisher. They move the markup into the template so it is generated from post data, guard aggregateRating behind a review-count check, and add the one-line JSON-parse gate to CI plus a Schema Debugger pass on a sample of posts when the template changes. The duplication is gone, authorship resolves, and a malformed block can no longer reach production. None of it required a plugin — just code they control.
Shipping disconnected blocks instead of a connected @graph. Redefining the same entity differently on every page. Faking aggregateRating, reviews or FAQs to clear a warning — a manual-action risk, especially when templated across a whole site. Templating optional fields without guarding them, so edge-case records emit empty or invalid markup. Editing with an un-anchored find-and-replace that hits the wrong line (always back up and match a unique string). And shipping generated schema with no validation gate, so one template break propagates everywhere.
No. JSON-LD is a JSON object in a <script type="application/ld+json"> tag in the head. You can write it by hand or generate it in your template — no plugin required.
A single array where each entity has an @id and others reference it by that id. It lets you define each entity once and express relationships, so Google gets one coherent model instead of disconnected, possibly contradictory blocks.
Add a geo object with latitude and longitude, and an openingHoursSpecification array of day/time ranges. Use your real coordinates and hours — never invent them.
No. Fabricated ratings or reviews are a manual-action risk, and templating them applies the violation sitewide. Omit optional properties unless you have genuine data.
Build the markup in your template layer from real page data, returning a stringified JSON-LD object, and guard optional fields behind a check so records without that data emit nothing rather than empty or fake values.
At minimum, extract each JSON-LD block and run JSON.parse so invalid JSON fails the build. For deeper checks, run representative pages including edge cases through a schema debugger whenever a template changes.