Hand-authoring a single JSON-LD block is a useful learning exercise. Doing it for thousands of product pages, articles, or events is not. This module bridges the gap between understanding schema and shipping it at scale — generating valid, nested, up-to-date structured data directly from your CMS data. It also sets the foundation for both platform tracks: WordPress (Track A) and Payload + Next.js (Track B).
Structured data that is authored manually in a CMS rich-text field or hard-coded in a template carries three compounding risks:
Product → Offer → AggregateRating → Review → Brand plus a BreadcrumbList and an Organization cross-reference. Keeping that structure correct by hand across templates is error-prone.
The solution is to treat structured data the same way you treat any other dynamic output: derive it from the same authoritative data source that drives your visible content. If the price in the database changes, the Offer.price in the JSON-LD changes automatically.
Every piece of structured data must map 1-to-1 to a field that is also displayed to users. This means your implementation architecture looks like:
CMS / Database field
│
├──► Rendered HTML (visible to users)
│
└──► JSON-LD property (visible to crawlers)
Both outputs are derived from the same field value. When the field changes, both outputs update on the next render or cache-revalidation cycle.
Regardless of the platform or language, the generation pattern is the same: build a plain data structure (object/dictionary/array), then serialise it as JSON and inject it inside a <script type="application/ld+json"> tag in the <head> or the <body> of the rendered HTML.
The key requirement for SEO is that the script tag must be present in the raw, server-rendered HTML returned on the first HTTP response — not injected later by client-side JavaScript. A crawler that does not execute JavaScript (or that has not yet processed a page through the render queue) must still receive the full structured data.
The following examples use plain JavaScript objects. These patterns apply directly to Node.js backends, Next.js server components, and any server-side templating environment.
Write a pure function that accepts a product data object and returns a structured data object. Keep schema-building logic separate from rendering logic.
/**
* buildProductSchema
* @param {Object} product - Data from CMS / database
* @returns {Object} - A valid Schema.org Product object
*/
function buildProductSchema(product) {
return {
"@context": "https://schema.org",
"@type": "Product",
"@id": `https://example.com/products/${product.slug}#product`,
"name": product.name,
"description": product.description,
"sku": product.sku,
"image": product.images.map(img => img.url),
"brand": {
"@type": "Brand",
"@id": "https://example.com/#brand",
"name": product.brand
},
"offers": {
"@type": "Offer",
"url": `https://example.com/products/${product.slug}`,
"priceCurrency": product.currency,
"price": product.price,
"availability": product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
"itemCondition": "https://schema.org/NewCondition"
}
};
}
Only add AggregateRating if reviews actually exist. Injecting a rating of 0/5 with 0 reviews is grounds for a manual action. Guard the conditional explicitly.
function buildProductSchema(product) {
const schema = {
"@context": "https://schema.org",
"@type": "Product",
"@id": `https://example.com/products/${product.slug}#product`,
"name": product.name,
"description": product.description,
"sku": product.sku,
"image": product.images.map(img => img.url),
"brand": {
"@type": "Brand",
"@id": "https://example.com/#brand",
"name": product.brand
},
"offers": {
"@type": "Offer",
"url": `https://example.com/products/${product.slug}`,
"priceCurrency": product.currency,
"price": product.price,
"availability": product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
"itemCondition": "https://schema.org/NewCondition"
}
};
// Only add AggregateRating if real review data exists
if (product.reviewCount > 0 && product.averageRating > 0) {
schema.aggregateRating = {
"@type": "AggregateRating",
"ratingValue": product.averageRating.toFixed(1),
"reviewCount": product.reviewCount,
"bestRating": "5",
"worstRating": "1"
};
}
return schema;
}
A product page commonly needs two or more distinct schema blocks: the Product itself and a BreadcrumbList. You can output these as either a JSON-LD array or as two separate <script> tags. Both are valid. The array approach is cleaner for injection via a single tag.
function buildBreadcrumbSchema(breadcrumbs) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumbs.map((crumb, index) => ({
"@type": "ListItem",
"position": index + 1,
"name": crumb.name,
"item": crumb.url
}))
};
}
// On the page-level data-assembly function:
function buildPageSchemas(product, breadcrumbs) {
return [
buildProductSchema(product),
buildBreadcrumbSchema(breadcrumbs)
];
}
Convert the object to a JSON string and embed it. Use JSON.stringify with proper escaping. When injecting into HTML, the one character sequence you must escape is </script> appearing inside a JSON string value, which would break the HTML parser.
/**
* Safely serialise a schema object for injection into an HTML script tag.
* Escapes the forward-slash in </script> sequences to prevent premature
* tag closure breaking the HTML parser.
*/
function schemaToScriptTag(schemaData) {
const json = JSON.stringify(schemaData, null, 2)
.replace(/<\/script>/gi, '<\\/script>');
return `<script type="application/ld+json">\n${json}\n</script>`;
}
// Usage:
const schemas = buildPageSchemas(productData, breadcrumbData);
const scriptTag = schemaToScriptTag(schemas);
// Inject scriptTag into the <head> of the rendered HTML
The @id property is how you tell the knowledge graph that the same entity appears in multiple places. Assign stable, canonical URIs as identifiers. When the same @id appears on multiple pages, Google can merge the signals.
// Organisation entity — defined once, referenced everywhere
const organizationSchema = {
"@context": "https://schema.org",
"@type": "Organization",
"@id": "https://example.com/#organization",
"name": "Example Ltd.",
"url": "https://example.com",
"logo": {
"@type": "ImageObject",
"url": "https://example.com/logo.png"
},
"sameAs": [
"https://www.linkedin.com/company/example",
"https://twitter.com/example",
"https://en.wikipedia.org/wiki/Example"
]
};
// On a product page — reference the organisation by @id only,
// no need to repeat all properties.
const offerWithPublisher = {
"@type": "Offer",
"seller": {
"@type": "Organization",
"@id": "https://example.com/#organization"
},
"price": 49.99,
"priceCurrency": "GBP"
};
This pattern is sometimes called @id referencing. The full entity definition lives on one canonical page (usually the homepage or an about page). Every other page that needs to reference that entity uses only the @id. Google stitches them together across the crawl.
In Next.js 13+ App Router, inject schema as a <script> tag directly inside your Server Component. Because this component runs on the server, the JSON-LD is present in the raw HTTP response — no JavaScript execution required from the crawler.
// app/products/[slug]/page.jsx (React Server Component)
import { getProductBySlug } from '@/lib/payload';
import { buildProductSchema, buildBreadcrumbSchema } from '@/lib/schema';
export default async function ProductPage({ params }) {
const product = await getProductBySlug(params.slug);
const schemas = [
buildProductSchema(product),
buildBreadcrumbSchema(product.breadcrumbs)
];
return (
<>
{/* Inject the JSON-LD into the document head via Next.js */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(schemas)
}}
/>
{/* Visible page content — same data source */}
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>£{product.price}</span>
</>
);
}
Critical note on dangerouslySetInnerHTML: React escapes HTML by default to prevent XSS. For a <script> tag containing JSON, you must use dangerouslySetInnerHTML or the output will be escaped and broken. The data going into this tag is your own schema object, not user input — this is safe and is the standard Next.js pattern.
In WordPress, hook into wp_head to output your JSON-LD. Build the schema as a PHP array and encode it with json_encode. Access the post or term data using standard WordPress functions. This approach bypasses your SEO plugin's schema module for that template, giving you full control.
<?php
// In your child theme's functions.php or a custom plugin
add_action( 'wp_head', 'my_product_schema' );
function my_product_schema() {
if ( ! is_singular( 'product' ) ) {
return;
}
$product_id = get_the_ID();
$price = get_post_meta( $product_id, '_price', true );
$sku = get_post_meta( $product_id, '_sku', true );
$in_stock = get_post_meta( $product_id, '_in_stock', true );
$brand = get_post_meta( $product_id, '_brand', true );
$rating = get_post_meta( $product_id, '_average_rating', true );
$count = get_post_meta( $product_id, '_rating_count', true );
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Product',
'@id' => get_permalink() . '#product',
'name' => get_the_title(),
'sku' => $sku,
'brand' => [
'@type' => 'Brand',
'name' => $brand,
],
'offers' => [
'@type' => 'Offer',
'price' => $price,
'priceCurrency' => 'GBP',
'availability' => $in_stock === 'yes'
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
],
];
// Conditionally add rating only if data exists
if ( $count > 0 && $rating > 0 ) {
$schema['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => number_format( (float) $rating, 1 ),
'reviewCount' => (int) $count,
];
}
echo '<script type="application/ld+json">';
echo wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT );
echo '</script>';
}
Use JSON_UNESCAPED_SLASHES to prevent PHP from escaping the forward slashes in your URLs. Use JSON_UNESCAPED_UNICODE if your content includes non-ASCII characters. wp_json_encode is preferred over json_encode in WordPress as it handles edge cases in the WordPress environment more safely.
Before writing a line of code, map your site's URL templates to their required schema types. This becomes the specification your schema generation functions implement.
Template Schema types required ───────────────────────────────────────────────────────────── Homepage Organization, WebSite (with SearchAction) Article / Blog post Article (or NewsArticle), BreadcrumbList, Person (author) Product page Product, Offer, AggregateRating*, BreadcrumbList Category / PLP BreadcrumbList (optional: ItemList) FAQ page FAQPage How-To guide HowTo Event page Event Local business LocalBusiness (or subtype), OpeningHoursSpecification Video page VideoObject Recipe Recipe Author profile Person, sameAs links
For each template, define which CMS fields map to which schema properties. Document this mapping explicitly. It serves as both a development guide and a QA checklist.
Generated schema must be validated at two levels:
Product with no offers block does not qualify for a product rich result).
Build validation into your development workflow, not just as a one-time check. When you add a new schema type or change a function, run both validators on a representative sample page. For large sites, script the validation using the Rich Results Test API and run it as part of your CI/CD pipeline.
If a field is empty in the CMS (the product has no reviews, the article has no author), do not output the corresponding schema property. An AggregateRating with ratingCount: 0 or a Person with an empty name will trigger a Search Console warning and may result in a manual action for misleading structured data.
// BAD — outputs schema even with no data
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 0,
"reviewCount": 0
}
// GOOD — guard the conditional before adding the property
if (product.reviewCount > 0) {
schema.aggregateRating = { ... };
}
The price in the Offer must exactly match the price displayed on the page, including currency. This is Google's most commonly flagged product schema issue. If your site shows a price range, use minPrice and maxPrice on the AggregateOffer type instead of a single price on Offer.
If your prices change and pages are cached, the schema can go stale immediately. Design your cache invalidation strategy to include schema accuracy. On ISR sites (Next.js), set appropriate revalidate intervals that match how frequently your data changes.
Any schema that is added to the DOM by JavaScript after the initial page load may not be seen by crawlers that do not execute JavaScript, and will be delayed even for those that do. Always inject JSON-LD in server-rendered HTML. Verify this with View Source — if the JSON-LD is not visible there, it is not server-rendered.
When outputting multiple schemas in a JSON array, include "@context": "https://schema.org" only on the outer array wrapper or on each individual top-level object — not on nested objects inside a schema. Nested types inherit the context from their parent.
// CORRECT — @context on each top-level object in the array
[
{
"@context": "https://schema.org",
"@type": "Product",
"brand": {
"@type": "Brand", // NO @context here — inherited
"name": "Acme"
}
},
{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
...
}
]
After implementation, perform this three-step verification for every template type:
Ctrl+U). Search for application/ld+json. If the script tag is there, it is server-rendered. If it is not there, JavaScript is injecting it client-side — which you must fix.
curl -A "Googlebot" https://example.com/products/your-slug | grep "application/ld+json". This simulates a non-rendering fetch and should return your schema block.
Using your preferred environment (Next.js, a Node.js Express server, or WordPress PHP), complete the following:
buildArticleSchema(post) function that accepts a CMS post object with fields: title, slug, excerpt, publishedAt, modifiedAt, author (name, url), image (url, width, height), breadcrumbs.Article and a BreadcrumbList, using @id to reference a separate Organization entity as the publisher.@id to build a cross-page entity graph. Define full entity descriptions once and reference them elsewhere.With this foundation, you are ready for the platform tracks: Track A (WordPress) will implement these patterns in PHP with WP hooks, and Track B (Payload + Next.js) will implement them as React Server Components with data fetched from Payload collections — both serving schema in raw, crawlable HTML from the very first byte.