Seonal

← Back to the guides

Product Schema Markup for Ecommerce: Product, Offer, Review

Oct 6, 2026 · 6 min read

On a product page the question isn't whether to use structured data. The question is which type, and which properties belong inside it. The difference is practical: a correctly filled Product can show price, availability and stars in search results. A badly filled one shows nothing — and Search Console sends you an error instead.

Here's what goes where, what's required, and where it usually breaks.

Product is the parent type. Offer and Review live inside it

A common misunderstanding: that price and reviews are separate schemas you bolt onto the page one by one. They aren't. A product page has one Product object, with these nested inside it:

  • offers — an Offer or AggregateOffer object (price, currency, availability)
  • review — individual reviews, type Review
  • aggregateRating — the summary score, type AggregateRating (average and count)
  • brand — a Brand object

So you don't publish three separate JSON-LD blocks on a product page. You publish one Product with correctly nested sub-objects. When they're split apart, Google often fails to connect them and no rich snippet appears.

The minimum skeleton that works

``json { "@context": "https://schema.org", "@type": "Product", "name": "Product name exactly as shown on the page", "image": ["https://example.com/image.jpg"], "description": "Product description", "sku": "ABC-123", "brand": { "@type": "Brand", "name": "Brand" }, "offers": { "@type": "Offer", "url": "https://example.com/product", "priceCurrency": "EUR", "price": "39.90", "availability": "https://schema.org/InStock" } } ``

That's enough for Google to recognise the product and the price. Stars only arrive with aggregateRating, and only when ratings genuinely exist on the page and a visitor can see them.

Offer: what's required and what breaks first

Three things in Offer matter for a product rich result: price, priceCurrency and availability.

Price as a number, not as display text. "price": "€39.90" is wrong. Correct is "price": "39.90" with the currency separately in priceCurrency. A currency symbol, a space, or a comma as the decimal separator will invalidate the value. This is the single most frequent mistake I find, because the template prints the price the same way the customer sees it.

Availability as a URL, not as a word. "availability": "In stock" won't pass. It needs one of the schema.org values:

  • https://schema.org/InStock
  • https://schema.org/OutOfStock
  • https://schema.org/PreOrder
  • https://schema.org/BackOrder

The price in the markup must match the price on the page. If the template writes the ex-VAT price into the schema while the page displays the price including VAT, Google can read that as a mismatch. In manual reviews that's a standard reason to lose the rich result.

Multiple variants mean AggregateOffer. When one URL covers several sizes or colours at different prices, use AggregateOffer with lowPrice, highPrice, priceCurrency and offerCount. Don't emit ten separate Offer objects when the page is a single selector.

priceValidUntil

Optional, but worth knowing. If you set it and the date passes, Google may stop showing the price. Either leave it out or move it forward automatically.

Review and AggregateRating: where shops take the most risk

AggregateRating is what produces the stars. It needs:

  • ratingValue — the average, for example "4.6"
  • reviewCount or ratingCount — how many ratings
  • optionally bestRating and worstRating if the scale isn't 1 to 5

Three rules get broken constantly.

1. The rating has to be visible on the page. Not in the markup and nowhere else. If your JSON-LD claims 4.8 from 36 ratings but the page shows no reviews at all, that breaks the structured data guidelines.

2. The rating has to be about the product, not about the shop. Stars from "we're rated 4.9 on a review platform" don't belong in Product.aggregateRating. That's a merchant rating, not a product rating. Company-level ratings use a different type (Organization with its own aggregateRating), and that one doesn't put stars next to a product.

3. Don't put aggregateRating on category pages. A category isn't one product, so Product doesn't belong there at all. A category is CollectionPage, or ItemList with the products listed.

If a product has exactly one review, use review with a Review object containing author, reviewRating and ideally reviewBody. An AggregateRating with a count of 1 reads like an error.

Which type belongs on which page

| Page | Main type | Add-ons | |---|---|---| | Product detail | Product | Offer, AggregateRating, Review, Brand, BreadcrumbList | | Category | CollectionPage or ItemList | BreadcrumbList | | Blog post | Article or BlogPosting | BreadcrumbList, Person | | Contact / about | Organization or LocalBusiness | PostalAddress, OpeningHoursSpecification | | FAQ section | FAQPage | — | | Tutorial | HowTo | — |

Add BreadcrumbList anywhere you have breadcrumb navigation. It's the cheapest way to turn a long URL in the search result into a readable path.

JSON-LD, Microdata or RDFa

Google supports all three. The practical answer is JSON-LD in a <script type="application/ld+json"> block, in the head or the body. Reasons:

  • you don't have to touch the product HTML template
  • one block is easy to check and easy to fix
  • a design change can't break it

Microdata (itemprop attributes inside the HTML) works, but during a redesign someone usually deletes those attributes without realising what they were for. And if you run both at once — Microdata from the theme plus JSON-LD from a plugin — you get duplicate, contradictory data. In that case switch one source off.

How to check whether it works

Three tools, each for a different job:

  1. Google's Rich Results Test — tells you whether the page is eligible for a rich result and which properties are missing. Test the live URL, not pasted code.
  2. The Schema.org Validator — stricter on syntax, and it flags properties Google ignores.
  3. Search Console, Enhancements > Products — the only source that tells you how many pages have an error across the whole shop. With a thousand products it's the only sensible starting point.

Understand the difference between "invalid" and "valid with warnings". Invalid items get no rich result at all. Warnings — a missing brand or sku, for example — don't block the rich result, but they limit what can be shown.

What happens after the fix

The rich result doesn't appear immediately. Google has to recrawl and reprocess the page. On a small shop that's days; on a large one it can be weeks. If you want a faster check, submit a handful of URLs through URL Inspection in Search Console for reindexing.

Second thing: a rich result is not an entitlement. Even with everything correct, Google may choose not to show it. Query type, device and the competition in the result all affect it. What you control is eligibility — making sure a comma in the price isn't what's holding you back.

How I handle it

When I crawl a site I check product pages for specific things: whether Product exists, whether offers has the price as a number with the currency separate, whether availability uses a URL value, whether aggregateRating matches what's actually visible on the page, and whether any category page is carrying Product by mistake.

For findings where I can assemble a correct output from the page content, I write the finished JSON-LD block ready to paste — not a note saying "add structured data". You approve it and deploy it through the plugin, the API or a CSV export.

Then I measure. I compare the 28 days before and after a deployed fix, so you can see whether CTR on product pages actually moved, or didn't.


This is written by a tool you can buy

The article was proposed and written by Seonal — the same one that finds the errors on your site, fixes them and measures the result. The audit is free.

Find out free how many errors your site has Pricing