Generating JSON-LD Structured Data from CMS Content
This guide, part of Metadata Injection & SEO Automation, turns CMS content into JSON-LD structured data automatically. It covers mapping content types to schema.org types, generating localized properties, building breadcrumbs from the page hierarchy, validating output in CI and the rules that keep structured data trustworthy.
Structured data tells search engines what a page is about in a machine-readable vocabulary, and it enables rich results such as article cards, product prices, FAQ answers and breadcrumb trails. In a traditional CMS, plugins generate it from templates. In a headless setup, nothing generates it unless the frontend does, and a common shortcut, a free-text JSON field that editors fill in, produces invalid, outdated or misleading markup within months. Generating it from the same fields that render the page keeps it correct by construction.
The Problem
A retailer’s CMS had a “structured data” text field on product pages where the SEO agency pasted JSON-LD. Prices in the markup were copied once and never updated, so search results showed outdated prices for hundreds of products. The German and French pages carried English product descriptions in their markup, because the field was not localized. When a validation tool finally ran, a third of the snippets had syntax errors from manual editing, and search console reported the product rich results as invalid.
How Generated Structured Data Works
Map each content type once. Decide which schema.org type each routable content type represents and which fields feed which properties. Articles become Article or NewsArticle with headline, description, image, dates and author; products become Product with name, image, description, brand and an Offer built from live commerce data; pages with FAQ blocks add FAQPage; every page adds a BreadcrumbList.
Use the page’s own data. The generator receives the same resolved content object the page renders, in the served locale, so every property matches what readers see.
Localize and declare the language. Text properties come from localized fields, and inLanguage is set to the served locale. URLs are absolute and point at the page’s own locale.
Validate automatically. Generated JSON-LD is checked against schemas in CI, so a model change that removes a field shows up as a failing test, not as a drop in rich results weeks later.
Implementation
The generator is a set of small mapper functions, one per content type, combined into a graph with @id references so entities can point at each other.
// lib/seo/jsonld.ts
const ORIGIN = process.env.SITE_ORIGIN!;
interface Article { id: string; title: string; summary: string; path: string; hero?: { url: string; width: number; height: number }; firstPublishedAt: string; updatedAt: string; author?: { name: string; path?: string } }
interface Crumb { name: string; path: string }
export function articleJsonLd(a: Article, locale: string, crumbs: Crumb[]) {
const url = `${ORIGIN}/${locale}${a.path}`;
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
"@id": `${url}#article`,
headline: a.title.slice(0, 110),
description: a.summary,
image: a.hero ? [{ "@type": "ImageObject", url: a.hero.url, width: a.hero.width, height: a.hero.height }] : undefined,
datePublished: a.firstPublishedAt,
dateModified: a.updatedAt,
inLanguage: locale,
mainEntityOfPage: url,
author: a.author ? { "@type": "Person", name: a.author.name, url: a.author.path ? `${ORIGIN}/${locale}${a.author.path}` : undefined } : undefined,
publisher: { "@id": `${ORIGIN}/#organization` },
},
{
"@type": "BreadcrumbList",
itemListElement: crumbs.map((c, i) => ({ "@type": "ListItem", position: i + 1, name: c.name, item: `${ORIGIN}/${locale}${c.path}` })),
},
],
};
}
Render it in the page as a script tag with safely serialized JSON. Escaping < prevents content from closing the script element early.
// components/json-ld.tsx
export function JsonLd({ data }: { data: object }) {
const json = JSON.stringify(data).replace(/</g, "\\u003c");
return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: json }} />;
}
For products, build the Offer from the same commerce response that renders the visible price, with price, priceCurrency and availability, so markup and page can never disagree. When the price is personalized or not visible to anonymous visitors, leave the offer out rather than marking up a price readers cannot see.
FAQ blocks
When a page contains an FAQ block, map its questions and answers to FAQPage with Question and Answer entities, using the rendered answer text. Only do this when the questions are visible on the page, and only for genuine questions and answers, not for marketing copy formatted as questions. Search engines show FAQ rich results only for some kinds of sites, but valid markup does no harm elsewhere.
Site-wide entities
Some entities describe the site rather than the page: the organization that publishes it, its logo, its social profiles and the website itself with its search action. Model them once, in a site settings entry in the CMS, and emit them with stable @id values, such as https://www.example.com/#organization, on the homepage and optionally on every page. Page-level entities then reference them by @id instead of repeating their properties, as the article example does with publisher. Localize the organization’s description and the website’s name per locale where they differ, but keep the @id identical across locales, since it identifies the same real-world organization. This structure keeps each page’s JSON-LD small and consistent, and a change to the logo or social profiles in the settings entry updates every page’s markup at the next revalidation.
Configuration Reference
| Content | Schema type | Notes |
|---|---|---|
| Article, blog post | Article or NewsArticle | Headline under 110 characters; dates in ISO 8601. |
| Product | Product with Offer | Offer from live commerce data only. |
| FAQ block | FAQPage | Only for visible questions and answers. |
| Every page | BreadcrumbList | From the route hierarchy, localized names. |
| Site-wide | Organization, WebSite | Once, referenced by @id. |
| Events, recipes, jobs | Event, Recipe, JobPosting | Add when the content types exist. |
Gotchas & Edge Cases
- Invisible content. Marking up information that is not on the page, such as ratings shown nowhere, violates search engine guidelines and can lead to manual actions. Generate only from rendered data.
- Fallback pages. Pages served in a fallback language should carry structured data in the served language with
inLanguageset accordingly, and follow the canonical to the source. - Free-text JSON fields. Remove them. They drift from content and break on manual edits.
- Unescaped script content. Text containing
</script>breaks the page if not escaped. Always escape<in serialized JSON.
Worked Example
The retailer removed its free-text JSON field, wrote mappers for products, categories and articles, and fed product offers from the live commerce response. German and French pages generated markup from localized fields. A CI test rendered fixtures for each content type and locale and validated the output against JSON schemas derived from the search engine’s documented requirements. Within two months, invalid product markup reports fell to zero, and price mismatches between search results and product pages disappeared, which also ended a steady trickle of customer complaints about prices seen in search.
Testing Structured Data
Test at two levels. Unit tests call each mapper with fixtures and compare the output with expected JSON, which catches regressions in field mapping. Schema tests validate the output for required and recommended properties per type, using JSON schemas you maintain from the search engine documentation; a missing required property fails the build. In the post-deploy audit, extract the JSON-LD from a sample of live pages per template and locale and run the same checks, and compare key values, such as price and headline, with the visible page content. Search console’s rich result reports remain the final check, but they lag by days; the automated tests catch the same problems before release.
Rollout Checklist
- Map each routable content type to a schema.org type and list its property sources.
- Generate JSON-LD from the resolved, localized page data.
- Build offers from live commerce data only.
- Add breadcrumbs from the route hierarchy on every page.
- Serialize safely and render in the server HTML.
- Validate in CI and in post-deploy audits.
Frequently Asked Questions
Should editors be able to edit structured data?
Only through normal content fields. If an editor needs a property changed, the underlying field should change, so page and markup stay consistent.
Is JSON-LD better than microdata?
For headless sites, yes: it is generated separately from the markup, easier to test and recommended by major search engines.
Does structured data improve rankings?
Not directly. It enables rich results, which can improve click-through rates, and helps search engines understand the page and its entities more precisely.
Where should the JSON-LD script go?
In the server-rendered HTML, usually in the head or at the end of the body. Search engines read both locations; what matters is that it is present in the server response, not injected later.
How do we handle multiple languages in one graph?
Do not mix them. Each page’s graph is in its served language, with inLanguage set; translations are separate pages with their own graphs.