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.
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 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.
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.
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.
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 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:
product object feeds both the JSON-LD and
the rendered JSX. If the price changes in the database, it changes in both places simultaneously.aggregateRating block is conditionally included only when reviewCount > 0,
preventing a rating in schema from appearing when no visible rating is shown on the page.
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.
price, priceCurrency, availability,
priceValidUntil — all must match the displayed price, currency, stock status, and any displayed
sale-end date.
ratingValue and reviewCount must match the
visible star rating and count. If the page shows "No reviews yet", omit the block entirely.
datePublished and dateModified must match
the visible publication and update dates. Using today's date programmatically when the content has not changed
is considered manipulation.
Question / Answer pair must be visible and expanded on
the page (not hidden behind a click, unless Google has specifically said accordions are acceptable — which
it has for native HTML <details>/<summary> elements).
startDate, location, eventStatus, and
eventAttendanceMode must match the visible event details. Cancelled events must update
eventStatus to EventCancelled — leaving them as EventScheduled
is a classic sync failure.
openingHoursSpecification, address,
telephone — these should come from the same data source as the visible contact details and be
kept current.
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.
revalidate to an
interval appropriate for how often the data changes. A product price that can change hourly should not be
ISR'd on a 24-hour window. Consider on-demand revalidation triggered by a webhook from your CMS or
e-commerce platform when inventory or price changes.
// 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.
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.
search.google.com/test/rich-results) — paste a URL or
code snippet. Shows which rich-result types Google detects, any errors, and any warnings about missing
recommended fields. This is the canonical tool for eligibility checks.
validator.schema.org) — validates against the
Schema.org specification itself, independent of Google's eligibility rules. Catches type errors,
missing required properties, and incorrect nesting.
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.
"ratingValue": null or "reviewCount": 0, that is invalid schema.
Guard against this: only include the aggregateRating block when review data genuinely
exists. Use conditional generation, not just serialisation of whatever the CMS returns.
price to be a number (or numeric string without currency symbols). Do not pass
"£29.99" — strip the currency symbol and pass "29.99" or 29.99.
Set priceCurrency separately using ISO 4217 codes ("GBP", "USD").
datePublished and dateModified must be ISO 8601.
"2024-03-15" or "2024-03-15T09:30:00+00:00" are correct.
"March 15, 2024" is not.
null, or placeholder value. An empty
"description": "" is not the same as omitting the field, and validators will flag it.
<details> element), there is a risk the content is not
considered "on the page" for schema-sync purposes. Prefer native HTML elements for expandable FAQ content
where rich results are important.
<script type="application/ld+json"> blocks and both declare a
Product, crawlers may process both but may only action one. Consolidate into a single block
per entity type, or use distinct @id values if you genuinely need to describe two separate
entities.
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.
wp_head or a custom block.
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:
@payloadcms/plugin-seo SEO fields vs content fields: the plugin
adds a dedicated SEO tab with meta title, description, and OG fields. These are editorial fields —
not programmatic mirrors of content. For structured data that must match visible content (prices,
ratings, dates), do not rely on the SEO plugin fields. Generate the JSON-LD from the collection's
content fields directly in your page component.
afterChange hook to call Next.js's revalidatePath or
revalidateTag whenever a document is updated. This ensures static pages are regenerated
with fresh schema as soon as content changes in the CMS.
When auditing an existing site, use this process to surface sync failures systematically:
price and availability in schema against the displayed values for a
sample of product pages — particularly recently sold-out products and recently repriced items.
You are ready to move on when you can: