← Back to Course Index

Module 3.6 — Generating Structured Data Programmatically

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).

Why Programmatic Generation Is Non-Negotiable

Structured data that is authored manually in a CMS rich-text field or hard-coded in a template carries three compounding risks:

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.

The Core Principle: Single Source of Truth

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.

The JSON-LD Output Pattern

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.

Building the Data Structure in JavaScript / Node.js

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.

Step 1 — A Simple Product Schema Function

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"
    }
  };
}
  

Step 2 — Adding Nested AggregateRating

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;
}
  

Step 3 — Composing Multiple Schemas on One Page

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)
  ];
}
  

Step 4 — Serialising and Injecting into HTML

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
  

Using @id to Build a Cross-Page Entity Graph

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.

Platform-Specific Implementation Patterns

Next.js (App Router) — the Payload Track approach

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.

WordPress — PHP-based Generation

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.

Template-to-Schema Mapping Architecture

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.

Validation Workflow

Generated schema must be validated at two levels:

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.

Common Generation Errors and How to Prevent Them

1. Outputting schema for content that does not exist on the page

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 = { ... };
}
  

2. Price mismatch

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.

3. Stale schema after price or stock updates

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.

4. Injecting schema client-side only

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.

5. Duplicate @context declarations in a nested array

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",
    ...
  }
]
  

Testing That Schema Is Truly Server-Rendered

After implementation, perform this three-step verification for every template type:

Hands-On Exercise

Using your preferred environment (Next.js, a Node.js Express server, or WordPress PHP), complete the following:

Module Summary

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.