← Back to Course Index

Module 3.7 — Keeping Structured Data in Sync with Visible Content

You can author perfectly valid, beautifully nested JSON-LD — and still get a manual action or a silent eligibility withdrawal from Google. The reason is almost always the same: the structured data on the page says one thing, and the visible, rendered content says something different. This module explains why that mismatch happens, how Google detects and penalises it, and how to build systems that prevent it at scale.

Why Google Cares About Sync

Google's rich-result eligibility guidelines include a hard requirement: the structured data on a page must accurately reflect the content a human visitor actually sees. This is not a soft recommendation — it is a policy that can result in:

The underlying principle is simple: structured data is a machine-readable representation of content that is already present and visible on the page. It is not an annotation layer you use to assert facts that are absent, hidden, or stale.

The Four Root Causes of Sync Failures

1. Hardcoded Schema in Templates

The most common cause. A developer writes a JSON-LD block directly into a theme template or component with literal values — "price": "29.99", "ratingValue": "4.8", "availability": "https://schema.org/InStock" — and the CMS data it was copied from later changes. The schema never follows.

<!-- ❌ Hardcoded — price will drift out of sync immediately -->
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Running Shoe Pro",
  "offers": {
    "@type": "Offer",
    "price": "29.99",
    "priceCurrency": "GBP",
    "availability": "https://schema.org/InStock"
  }
}
</script>

The fix is always the same: generate schema values from the same data source that drives the visible content. We cover the mechanics in the generation section below.

2. Client-Side Injection Without Matching Visible Content

A JavaScript framework injects a <script type="application/ld+json"> block into the DOM after hydration. If the data driving the schema comes from a different API call, a different cache layer, or a different timing window than the data driving the rendered UI, they can diverge. You end up with a "price" in schema that was fetched at one moment and a displayed price that was fetched at another.

There is a second, subtler problem: if the structured data is injected client-side and the rendered content is only available after JavaScript executes, both are in the same render wave — but the schema is still not present in the raw HTML. This matters for the indexing pipeline and is covered in Module 0.3d.

3. Stale Aggregated Data (Ratings, Inventory, Pricing)

Aggregate ratings are a common source of staleness. A site generates schema at deploy time or at a long cache interval — say, once every 24 hours. But reviews are written continuously. The "ratingValue" and "reviewCount" in the schema can lag the displayed values by hours or days, especially after a spike of negative reviews.

Inventory and pricing are worse. An "availability": "https://schema.org/InStock" value can persist in a cached page long after the product sells out. This is exactly the scenario Google's guidelines are written to prevent — a rich result promises a price and availability that no longer reflects reality when the user clicks through.

4. CMS Field Disconnection

Content teams edit fields in the CMS that drive the visible page. If the schema generation logic does not read from those same fields — for example, because a developer duplicated data into a separate "SEO schema" custom field that nobody updates — the two surfaces diverge over time. This is especially common in WordPress sites where a bespoke JSON-LD block was added to a custom field in the post editor and then forgotten.

The Correct Architecture: Single Source of Truth

The principle that eliminates all four root causes is: generate structured data programmatically from the same CMS fields or database records that render the visible content. There should be one source of truth, and both the schema and the page content should be derived from it at the same time, in the same render pipeline.

/* ✅ Correct pattern — schema built from the same data object as the UI */

// This could be a Next.js Server Component, a PHP template, or any server-side renderer.
// The key: `product` is fetched once, used for both the visible HTML and the JSON-LD.

export default async function ProductPage({ params }) {
  const product = await getProduct(params.slug); // single fetch

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    "name": product.name,
    "description": product.description,
    "image": product.images.map(img => img.url),
    "brand": {
      "@type": "Brand",
      "name": product.brand.name
    },
    "offers": {
      "@type": "Offer",
      "url": `https://example.com/products/${product.slug}`,
      "priceCurrency": product.currency,
      "price": product.price,          // same field rendered in the UI
      "availability": product.inStock
        ? "https://schema.org/InStock"
        : "https://schema.org/OutOfStock",
      "itemCondition": "https://schema.org/NewCondition"
    },
    "aggregateRating": product.reviewCount > 0 ? {
      "@type": "AggregateRating",
      "ratingValue": product.averageRating,  // same value rendered visibly
      "reviewCount": product.reviewCount
    } : undefined
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />

      {/* Visible content — driven by exactly the same `product` object */}
      <h1>{product.name}</h1>
      <p className="price">{product.currency} {product.price}</p>
      <p className="stock">
        {product.inStock ? "In Stock" : "Out of Stock"}
      </p>
      <div className="rating">
        {product.averageRating} ({product.reviewCount} reviews)
      </div>
    </>
  );
}

Notice three things about this pattern:

Field-Level Mapping: What Must Match

Not every schema property needs to have a visible counterpart — some are machine-readable identifiers like @id or structural like @context. But for the properties that Google uses in rich results, the rule is strict: if it appears in schema, it must be visible on the page. The following table covers the highest-risk fields.

Handling Dynamic Data: Caching Strategy Matters

Schema sync is a caching problem as much as it is a code problem. If your page is statically generated at build time or cached at the CDN, the schema baked into that HTML will be as stale as the cache. For data that changes frequently — prices, inventory, ratings — you need a revalidation strategy that keeps the cache consistent with the database.

// Next.js App Router — on-demand ISR revalidation from a webhook
// Route: /app/api/revalidate/route.ts

import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  const { secret, slug } = await req.json();

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Invalid token' }, { status: 401 });
  }

  // Revalidate the specific product path when its data changes in the CMS
  revalidatePath(`/products/${slug}`);

  return NextResponse.json({ revalidated: true, slug });
}

This approach means your CMS or e-commerce platform sends a webhook when a product is updated, your Next.js app revalidates just that path, and within seconds the cached HTML — including the embedded JSON-LD — reflects the current data.

Validation Workflow: Before and After Deploy

Programmatic generation reduces drift but does not eliminate it entirely. Bugs in the generation logic, bad data in the CMS, or unexpected null values can all produce invalid or mismatched schema. Build validation into your deployment workflow rather than relying solely on manual spot-checks.

Manual Validation Tools

Automated Validation in CI/CD

For teams deploying continuously, manual validation does not scale. Build automated checks that run on every deploy:

// Example: extracting and validating JSON-LD in a Playwright or Puppeteer E2E test
// Runs against your staging environment on every PR

import { test, expect } from '@playwright/test';

test('Product page JSON-LD matches visible price', async ({ page }) => {
  await page.goto('/products/running-shoe-pro');

  // Extract the JSON-LD from the page
  const schemaText = await page.$eval(
    'script[type="application/ld+json"]',
    el => el.textContent
  );
  const schema = JSON.parse(schemaText);

  // Extract the visible price displayed to users
  const visiblePrice = await page.$eval('.price', el => el.textContent.trim());

  // Assert the schema price matches the visible price
  expect(schema.offers.price.toString()).toBe(
    visiblePrice.replace(/[^0-9.]/g, '')
  );

  // Assert availability in schema matches visible stock status
  const visibleStock = await page.$eval('.stock', el => el.textContent.trim());
  const expectedAvailability = visibleStock.includes('In Stock')
    ? 'https://schema.org/InStock'
    : 'https://schema.org/OutOfStock';

  expect(schema.offers.availability).toBe(expectedAvailability);
});

These tests do not replace the Rich Results Test — they are a regression guard. They catch the class of bugs where a code change accidentally breaks the generation logic or introduces a mismatch between the data path feeding the schema and the data path feeding the UI.

Common Gotchas and How to Handle Them

WordPress-Specific Sync Considerations

WordPress presents a particular challenge because SEO plugins generate schema somewhat independently of the content, and WooCommerce product data changes frequently without always triggering cache purges or plugin updates.

Payload CMS-Specific Sync Considerations

In a Payload + Next.js stack, the single-source-of-truth pattern is natural because your Next.js components fetch data from Payload's REST or GraphQL API and use the same response to generate both the UI and the JSON-LD. The risks are slightly different:

Audit: How to Check for Sync Failures on a Live Site

When auditing an existing site, use this process to surface sync failures systematically:

Milestone

You are ready to move on when you can: