Product Schema on Collection Pages: Fixing the Real Errors

What Happened – Product Schema on Collection Pages

A Search Console Merchant listings report came back with three warnings sitting on exactly 1,989 items each: Missing field “hasMerchantReturnPolicy” (in “offers”), Missing field “shippingDetails” (in “offers”), and Missing field “description”. Identical counts across three unrelated properties is the signature of a template problem rather than a data problem, so the obvious brief writes itself… add the three missing fields, clear the report, move on.

That brief would have been wrong on every count.

The first thing the URLs revealed is that these were not product detail pages (PDPs). They were collection pages (PLPs). Every product card in the grid carried its own Product microdata block, complete with a nested Offer, so a single category URL was generating dozens of independent merchant listing items.

Run one of those collection URLs through the Rich Results Test and you get a stack of separate detected items, each carrying its own copy of the same three warnings. Nineteen hundred and eighty-nine items across a handful of collection templates is arithmetic, not a catalogue audit.

The second thing is what was inside the offer. The card rendered a price of £32,95 on screen. The markup underneath said this:

html

<div itemprop="offers" itemscope itemtype="https://schema.org/Offer">
  <meta itemprop="priceCurrency" content="EUR">
  <meta itemprop="price" content="3295">
  <link itemprop="availability" href="https://schema.org/InStock">
  <link itemprop="url" href="/hr-eu/products/riboflavin-400-mg-60-vegetarijanskih-kapsula">
  <span class="voo-card__price-cur">£</span>
  <span class="voo-card__price-int">32,95</span>
</div>

Three numbers, three different answers.

The visible price is 32,95 with a comma, which is correct formatting for a Croatian-locale storefront. The structured data says 3295 – the comma has been stripped rather than converted to a decimal point, so the machine-readable price is one hundred times the real one. The declared currency is EUR while the visible symbol is a hardcoded pound sign. Google is being told this bottle of riboflavin costs €3,295.00.

The three warnings were real.

They were also the least important thing on the page.

Why Does It Matter – Product Schema on Collection Pages

  • A stripped decimal separator is a pricing lie at catalogue scale.

Google is explicit about this: for decimal numbers, use a dot rather than a comma, and in Microdata you can use the content attribute to override the visible content… showing users whatever style convention you want while still satisfying the dot requirement for structured data.

The tool for exactly this problem already exists in the spec. What happened here was the opposite: the content attribute was populated by stripping the separator out of a localised string, which turns 32,95 into 3295 rather than 32.95.

  • Merchant Center compares three things and disapproves when any of them disagree.

When Googlebot crawls a product landing page, it checks the feed price against the visible price and against the structured data, currency included. All three have to agree.

A mismatch triggers preemptive item disapproval of the affected products until they line up again – this is the “Mismatched value (page crawl) [price]” disapproval that eats paid and free listings alike. A 100x error with a currency conflict on top is not a borderline case.

  • Localisation failures show up in markup before they show up anywhere else.

A hardcoded £ on a Croatian-locale, EUR-priced storefront, alongside a “UK delivery” promise and English interface strings on a translated product title, is a trust problem for shoppers and a consistency problem for crawlers.

Multi-market Shopify builds fail here constantly, because the price value gets localised by the platform while the currency symbol, delivery copy and schema serialisation stay hardcoded in the theme.

  • Fixing the three warnings as briefed would have made the page worse.

Adding description, shippingDetails and hasMerchantReturnPolicy to every card means repeating a full return policy and a full shipping policy 24 to 48 times per collection page, across every collection, in every market.

That is a significant payload increase on the exact template type where rendering performance matters most, in service of an eligibility the page cannot claim anyway.

  • Which is the real point: category pages are not merchant listing candidates.

Google’s technical guidelines for merchant listings state that Product rich results only support pages that focus on a single product or its variants, and recommend adding markup to product pages rather than to pages that list products or a category of products.

Marking up every card as a standalone Product does not make the collection page eligible. It generates warnings, inflates the report until nobody reads it, and duplicates entity data that already exists – correctly, in most cases – on the product detail pages those cards link to.

  • And there is an opportunity being missed.

The correct markup for a category page is an ItemList, and ItemList combined with Product is exactly what Google’s carousel rich results beta consumes. That feature is available in EEA countries, Turkey and South Africa, and in the EEA it covers shopping queries.

A Croatian-market collection page is squarely inside that footprint. The current implementation is doing the wrong work in the wrong place while leaving the right work undone.

Who Is Affected?

  • Shopify Markets and other multi-market merchants.

The highest-risk group by a distance. Presentment currencies, translated titles, market-specific URL prefixes and locale-aware number formatting all interact with theme-level schema serialisation, and theme code rarely accounts for any of it. If your storefront serves more than one currency, assume your card and product markup needs auditing per market rather than once globally.

  • Anyone running a forked or heavily customised theme.

Stock Shopify and WooCommerce templates usually get the price serialisation right.

Custom card components – the ones with delivery countdowns, viewing counters, wishlist buttons and trust badges bolted on – are where hand-rolled itemprop attributes appear and where separators get stripped.

  • Stores running a schema app alongside theme markup.

Two sources of truth on the same page produce competing Product nodes, and the one Google picks is not always the one you tested.

  • Headless and composable builds.

A single shared serialiser means one fix corrects everything, and one regression corrupts everything. Locale-aware number formatting in the presentation layer leaking into the data layer is the classic failure.

  • High-SKU catalogues in supplements, health, beauty and grocery.

Large collection grids multiply every card-level defect by the number of cards. These verticals also face tighter scrutiny on accuracy and trust signals, and a visibly implausible price is the fastest way to fail that scrutiny.

  • Retailers using free listings

If Merchant Center is in the picture, this stops being a Search Console hygiene item and becomes a revenue item the same week.

What Should Businesses Do?

Fix the price serialisation first

The rule is simple: the machine-readable value is a plain decimal number with a dot, no currency symbols, no thousands separators, no spaces. The human-readable value can be formatted however the market requires. Microdata gives you both.

Wrong:

html

<meta itemprop="price" content="3295">
<span class="voo-card__price-cur">£</span>
<span class="voo-card__price-int">32,95</span>

Right:

html

<meta itemprop="priceCurrency" content="EUR">
<meta itemprop="price" content="32.95">
<span class="voo-card__price">32,95 €</span>

On Shopify, product.price is returned in the minor unit of the market’s presentment currency. Divide it – do not reformat a display string:

liquid

{%- assign v = product.selected_or_first_available_variant -%}
<meta itemprop="priceCurrency" content="{{ cart.currency.iso_code }}">
<meta itemprop="price" content="{{ v.price | divided_by: 100.0 }}">
<span class="voo-card__price">{{ v.price | money }}</span>

Two habits to retire. Never derive a structured data value by string-manipulating a rendered price – remove: ',', replace: '.', '' and their variants are how 32,95 becomes 3295. And never hardcode a currency symbol in a template that serves more than one market; money already knows the market’s format.

For a card representing a product with several differently-priced variants, a single price is the wrong shape. Use AggregateOffer with lowPrice and highPrice drawn from product.price_min and product.price_max.

Fix the currency and the surrounding copy

Declare the presentment currency from the market context, not from a theme setting: {{ cart.currency.iso_code }} or {{ localization.country.currency.iso_code }}. Then walk the rest of the card for hardcoded market assumptions — delivery promises, returns copy, stock language, interface strings.

A Croatian product title sitting under an English “UK delivery” badge on a EUR price is a conversion problem before it is ever an SEO one.

Change what collection pages emit

Remove per-card Product microdata from collection templates. Replace it with one CollectionPage containing a single ItemList, emitted once per page.

json

{
  "@context": "https://schema.org",
  "@type": "CollectionPage",
  "@id": "https://example.com/hr-eu/collections/vitamin-b2-riboflavin#collectionpage",
  "name": "Vitamin B2 (Riboflavin)",
  "url": "https://example.com/hr-eu/collections/vitamin-b2-riboflavin",
  "mainEntity": {
    "@type": "ItemList",
    "itemListElement": [
      {
        "@type": "ListItem",
        "position": 1,
        "item": {
          "@type": "Product",
          "name": "Riboflavin 400 mg — 60 vegetarijanskih kapsula",
          "image": "https://example.com/cdn/shop/files/riboflavin-400mg.jpg",
          "url": "https://example.com/hr-eu/products/riboflavin-400-mg-60-vegetarijanskih-kapsula",
          "brand": {
            "@type": "Brand",
            "name": "Seeking Health"
          },
          "offers": {
            "@type": "Offer",
            "price": 32.95,
            "priceCurrency": "EUR",
            "availability": "https://schema.org/InStock"
          }
        }
      }
    ]
  }
}

Four points that decide whether this works:

  • itemListElement.item needs name, image and url as a minimum, and url must be the canonical URL of the detail page – absolute, unique, and on the same domain. Relative hrefs and anchor links are not supported.
  • Mark up every item shown on the page. For paginated collections, emit a fresh ItemList on each page containing that page’s items. For infinite scroll, mark up the entities initially loaded into the viewport.
  • The list needs at least three items to be eligible for the carousel beta.
  • offers.price and offers.priceCurrency are the recommended offer properties here – the same decimal rules apply, and if no currency is supplied Google defaults to USD.

Prefer the master image URL over the cropped 400px thumbnail your grid renders. And nest brand as a Brand object with a name rather than dropping itemprop="brand" onto a paragraph of text.

Keep merchant listing depth on product pages only

description, shippingDetails, hasMerchantReturnPolicy, sku, gtin, mpn, itemCondition, validFrom and priceValidUntil belong on the PDP, where the page genuinely focuses on one product and can support the eligibility. Define shipping and return policies once at organisation level and reference them from each offer by @id rather than repeating them per product – and certainly not per card.

Once collection templates stop emitting Product nodes, the three warnings disappear at source. You are not suppressing them; you are removing markup that should never have been there.

Rolling it out across the site

  1. Confirm the scope. Export the affected URLs from the Merchant listings report and check how many are collection, search, or other list-type templates versus genuine PDPs. Identical warning counts across unrelated fields point at one or two templates.
  2. Inventory every source of Product markup. Theme sections, card snippets, schema apps, third-party review widgets, tag-manager injections. Two Product nodes on one page is a fix that will not hold.
  3. Audit price serialisation per market, not once. Render one card in each market and diff the content attribute against the visible price. This is a ten-minute check that catches the expensive bug.
  4. Ship the price and currency fix first, on its own. It is small, high-value, and independently verifiable. Do not bundle it with the structural change.
  5. Then ship the template change – strip card microdata, add the CollectionPage and ItemList block – behind a staging validation gate.
  6. Validate before deploying. Rich Results Test plus the Schema Markup Validator on one URL per template per market. Confirm the collection URL now reports as a carousel item list and no longer as a stack of merchant listings.
  7. Canary, then scale. Deploy to a handful of live collection URLs, run URL Inspection live tests, then release catalogue-wide, resubmit sitemaps and use Validate Fix in Search Console.
  8. Watch Merchant Center in parallel. If price disapprovals were already open against these products, they should clear on the next crawl cycle once feed, page and markup agree.

Guardrails worth encoding as tests

  • Structured data price parses as a float and equals the visible price to two decimal places.
  • Price value contains no currency symbol, thousands separator, space or comma.
  • priceCurrency matches the market’s presentment currency on every localised URL.
  • No hardcoded currency symbols anywhere in card or PDP templates.
  • Exactly one Product node per PDP; zero on list templates.
  • Every ItemList item URL is absolute, canonical, unique and same-domain.
  • Item count in the ItemList matches the number of cards rendered on that page.
  • A rendering test per market, not just per template.

What We’re Watching Next

  • Carousel eligibility becoming a real reason to mark up category pages properly.

The beta is EEA, Turkey and South Africa only today, and shopping queries are in scope in the EEA. Retailers with European market storefronts have a concrete incentive to get ItemList right now rather than treating category schema as decoration.

Requirements may still change while the feature is in beta – that is worth building for, not waiting on.

  • Markup-to-page parity being enforced harder.

Merchant Center already compares feed, visible price and structured data. As shopping moves further into AI-mediated surfaces, the tolerance for a page that says one thing to humans and another to machines gets thinner, not wider.

Expect parity to be checked on more attributes than price.

  • Localisation treated as a structured data discipline.

Multi-market storefronts currently localise the front end and forget the data layer. We expect per-market structured data QA to become a standard line item in enterprise SEO retainers within the next year, in the same way hreflang validation did.

  • Agentic shopping raising the cost of a bad number.

When a person sees €3,295 on a vitamin bottle, they laugh and scroll past. When an automated shopping agent ingests it, it silently deprioritises the merchant and nobody ever finds out why.

About Szymaniak Digital

Szymaniak Digital is an enterprise AI SEO consultancy working with senior marketing teams on technical SEO, structured data architecture, and generative engine optimisation. We audit product and category markup at catalogue scale across multi-market international storefronts, and work alongside development teams to implement fixes that survive the next theme update.

If your Merchant listings report has warnings nobody has read in six months, the warnings are rarely the story. Book a structured data audit.

Need More Enquiries from Google and ChatGPT? 📞 0330 223 7866

X
Scroll to Top