A headless storefront can show a product with three prices at once: one in the commerce API, one in the visible page, one in the JSON-LD, and they can all be different without anyone having made a mistake. Each was correct when it was written; they were written at different times by different code and cached in different places. This post explains how that happens in a headless stack, what our scanner can and cannot see of it, and the single-source pattern that makes the three prices one.
What headless separates
Headless means the commerce backend, catalogue, pricing, inventory and cart, is a service with an API, and the storefront is a separate application that calls it. The separation is the point: the storefront can be a Next.js, Nuxt or Astro site with its own deploy cycle, and the backend can change without a theme changing. It also means that the page an agent fetches is assembled from several requests, made at several times, by several pieces of code.
That is where the three prices come from.
Three prices, three paths
The API price is the live one. It is what the checkout will charge, and it changes when a promotion starts, a variant is selected, a currency is switched or stock runs out.
The page price is whatever the storefront rendered. In a stack that renders at build time or on an incremental schedule, that is the API price as of the last build or revalidation. In a stack that fetches the price in the browser, it is live, but it is only in the DOM after JavaScript runs, and the raw HTML carries a placeholder or nothing. Many stacks do both: a server-rendered list price with a client component that overwrites it.
The schema price is what the JSON-LD block says. It is usually produced by a different template from the visible price, sometimes by a CMS plugin or a tag manager, often from a field that is copied from the catalogue on a schedule rather than read from it on request. It has its own cache, and nobody looks at it, because nobody sees it.
Add variants, where the block describes the default and the page shows the selected one; sale pricing applied by a promotions layer the template does not know about; and availability copied from a nightly feed, and three prices is the ordinary condition of a stack with three data paths.
What the scanner sees
The checks below read raw HTML, the same fetch an agent without a browser makes, except where a rendered comparison is stated. Three of them touch this problem, and it is worth being exact about how far each one goes.
A7, five points, compares visible text in the raw homepage HTML against the rendered DOM. Price fetched in the browser is text the raw fetch does not have. On a product-heavy homepage the missing prices and stock lines lower the ratio; below 0.8 the check drops to three points and below 0.5 to zero. The same fact from the agent's side: it never sees the client-side price at all. Whatever the raw HTML says is, for that agent, the price.
B2, five points, collects every @type across the crawled pages and, for a site typed e-commerce, awards three of them if Product or Offer appears anywhere in the set, plus two for Organization or WebSite. It does not open the Offer. A block with a stale price scores exactly the same as one with the right price.
C5, four points, has one signal for schema. As the engine in rubric v1.0.1 computes it, the signal is a presence test: it takes the first crawled page whose URL contains /product/, /products/, /shop/, /collections/ or /item/, falls back to the homepage if none was crawled, and awards the point if the raw HTML of that page contains an "@type": "Offer". The evidence field is offerSchema. It is not a comparison of the schema price against the page price, and Product and Offer schema that matches the page sets out why the comparison is yours to run and gives a script for it.
So the scanner can tell you that an Offer is in the raw HTML of a product page, that your homepage text survives a plain fetch or does not, and that the right types exist. It cannot tell you that the three prices disagree. Put the facts together and the failure case is clear: a stack that server-renders the JSON-LD from a cached catalogue and fetches the visible price live passes C5's schema signal, passes B2, loses some of A7, and serves every plain-fetch agent the stale price as the only price on the page.
Why that is worse than no schema
An agent reading structured data treats it as the site's own statement of price and availability, which is what it is for. If the statement is stale, the agent proceeds on it: reports a budget as met, adds the item to a comparison, or reaches a checkout that charges something else. Guest checkout is an agent feature covers what happens at that last step; the short version is that the failure lands on the person, not in your logs. Absent schema costs a machine-readable price. Wrong schema costs the transaction and a little of the standing that decides whether you are cited next time. The Offer definition is a promise about the page it sits on.
One source, one request
The fix is architectural and it is one rule: a product page reads price and availability once, at request time, from one source, and everything on the page, visible or structured, is rendered from that one object.
// app/products/[slug]/page.tsx (Next.js App Router, server component)
import { getOffer } from "@/lib/commerce";
export default async function ProductPage({ params }: { params: { slug: string } }) {
// One call. The visible price and the JSON-LD both come from `offer`.
const { product, offer } = await getOffer(params.slug);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
sku: product.sku,
offers: {
"@type": "Offer",
url: product.url,
price: offer.price,
priceCurrency: offer.currency,
availability: `https://schema.org/${offer.inStock ? "InStock" : "OutOfStock"}`,
},
};
return (
<main>
<h1>{product.name}</h1>
<p>
{offer.currency} {offer.price}, {offer.inStock ? "in stock" : "out of stock"}
</p>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</main>
);
}
The consequences of the rule do the rest.
- Cache the page, not the parts. If the page is cached or revalidated on a schedule, the price and the schema go stale together and come back together, which is honest. When the promotion engine or the inventory system changes something, invalidate the page for that product rather than waiting out the interval.
- Client components receive the price; they do not fetch it. A variant selector that changes the price on the page should do so from data the server already rendered, or by navigating to the variant's own URL, which carries its own Offer. A component that fetches on mount reintroduces the second path.
- One URL per sellable variant. An Offer describes one price. A page that can show several prices for one URL cannot have a truthful block, so give each variant a URL and let the block match it.
- No schema from a tag manager. A block injected after load is absent from the raw fetch, so both C5 and the agent miss it, and it will have its own data path besides.
- Test the pair. In the storefront's test suite, render a product page and assert that the
pricein the Offer appears in the visible text. It is a short test and it catches every regression above at the pull request.
If the commerce backend is one you cannot change, this is still a storefront-side fix: the storefront is the only place the three paths meet, and it is the layer this pattern belongs to. Aligning the paths is the kind of work the implementation service does.
Check your own site
The free scan reports offerSchema, productPageFound and the A7 parity ratio with the raw evidence, and the e-commerce leaderboard shows how other shops scored on the same checks. The definitions are on the methodology page under understanding and actionability.