← Insights

Shopify product schema: what your theme emits, and what Google reads

Dawn and Horizon emit one Product node from a Liquid filter. What it carries, what it leaves out, why the stars on the page never reach it, and a five-minute check.

10 min read

Contents — 6 sections

A product page has two readers, and the second one only trusts the script tag

A person reads the page. A machine reads a block of JSON inside a <script type="application/ld+json"> tag, and for the facts that matter at the moment of a search — what the thing is, what it costs, whether it is in stock, whether anyone rated it — that block is the version it believes. Google's introduction to Product structured data lists what the block can put directly into a result: price, availability, review ratings and shipping information, in web results, Google Images and Lens.

Google draws one line through the whole subject. There are two classes of product markup. Product snippets are for pages where the product cannot be bought — editorial reviews, comparison pages. Merchant listings are for pages where a shopper can buy from you, and that is the class every Shopify product page falls into. The merchant listing guide is strict about the shape: the page must focus on a single product or the variants of one product, Google may attempt to verify the product data before showing it, and the markup belongs in the initial HTML rather than in JavaScript, because dynamically generated markup makes Shopping crawls less frequent and less reliable.

The answer engines are less documented than Google, and we would rather say so than pretend otherwise. Google's own page on AI features says there is no special structured data for AI Overviews or AI Mode, then lists making sure your structured data matches the visible text on the page among the practices that still apply. Read that as the floor. How a store gets named by an assistant at all is a different question with its own page, how to get your store recommended by ChatGPT. This one is about the block.

Your theme already emits one node from one line, and it is thinner than the page

On Dawn, open sections/main-product.liquid in the code editor and search for structured_data. The whole of the theme's product markup is this:

<script type="application/ld+json">
  {{ product | structured_data }}
</script>

Dawn's line sits near the bottom of the section. Horizon does the same at the top of sections/product-information.liquid, with closest.product in place of product. Most themes built since either shipped are a copy of one of them.

What that filter produces is documented on the structured_data reference: a schema.org Product if the product has no variants, a ProductGroup if it has one or more. The example output carries @id, name, description, brand (your vendor field), category, one image, the product url, and an offers object holding price, priceCurrency, availability and the variant URL. That is the complete list.

Set it against what Google asks for. Merchant listings require three properties on the Product — name, image and a nested Offer — and on the Offer, price and priceCurrency. The default meets all of them; a Dawn store with nothing added has valid, minimal markup, and the Rich Results Test will say so. Then there is the recommended list, and Google's general guidelines are blunt about it: the more recommended properties you provide, the higher quality the result, and rich result ranking takes the extra information into consideration. For merchant listings the recommended set includes aggregateRating, review, sku, gtin, mpn, brand, description, color, size, material, audience, and shipping and return details. The filter gives you brand, and description if you wrote one. It gives you none of the identifiers, and it gives you no rating.

Two things the default gets right that a replacement often breaks. availability is a real schema.org URL, and Google lists ten permitted values and says not to specify more than one. And the image is absolute, on your CDN and crawlable — Google's rule is that every image URL in structured data must be crawlable and indexable, or it cannot be shown.

The stars on the page are not in the node, and that is what a detached rating is

Scroll up in the same Dawn section to the rating block and you will find where the stars come from:

{%- if product.metafields.reviews.rating.value != blank -%}
  {{ product.metafields.reviews.rating.value }} / {{ product.metafields.reviews.rating.value.scale_max }}
  ({{ product.metafields.reviews.rating_count }})
{%- endif -%}

reviews.rating and reviews.rating_count are not the theme's invention. They are two of Shopify's standard metafield definitions, and the definition says what they are for: product rating apps should write to them whenever a product's rating changes, and themes that display ratings should read them. Dawn does read them — for the stars. It does not read them for the JSON-LD, because the JSON-LD is a filter it cannot reach into.

So the ordinary state of a Dawn store with a review app installed is this: visible stars and a review count on the page, a Product node in the source with no aggregateRating at all, and a product snippets warning in the Rich Results Test that says exactly that — offers provided without review or aggregateRating. The rating exists. Nothing connects it to the product.

Review apps know this, which is why most of them inject a block of their own. That is where the second failure comes from, and it takes one of two forms.

The first is a second Product node. Now the page asserts two products: the theme's, with the price and no rating, and the app's, with the rating and often a price of its own, formatted its own way. Google's technical guideline is that product rich results only support pages that focus on a single product, and its review snippet guidelines say to refer clearly to a specific product by nesting the rating inside that product's markup. Two nodes is the opposite of clear, and a page that states two prices for one item is a page with a reason to be distrusted.

The second is a bare AggregateRating at the top level with an itemReviewed naming the product. Google accepts that shape in principle — itemReviewed is required precisely when the rating is not nested — but it turns a fact into a matching problem. The theme's node identifies itself as /products/handle#product; the app's itemReviewed is usually a name string. JSON-LD has one mechanism for saying two blocks describe one thing, the @id keyword, which the W3C specification defines as the way to identify a node uniquely within a document, and Google's general guidelines say to use it when separate items on a page belong together. Almost no review app sets it to match the theme's.

Two guidelines in the same Google document decide what you may do about this. The rating you mark up must be visible on the page — if you use AggregateRating, users should be able to see that aggregate rating — and you must not aggregate ratings from other websites. And one guideline people over-apply: the rule that an entity cannot carry star ratings about itself is written for LocalBusiness and Organization markup, not for Product. Reviews of your products on your own store are eligible. A star rating on your homepage's Organization node is not, and putting one there is a reliable way to get the whole property looked at.

Check it in five minutes, with the test and one line of console

Paste your best-selling product URL into the Rich Results Test. It fetches the page as Google would and reports what it detected, grouped by feature — you want to see both Merchant listings and Product snippets, each with an item count. Open each. Errors make an item ineligible; warnings do not, but they are the list of recommended properties you have not provided, and it is the list you are about to work through.

Then get the raw picture, because the test summarises. On the live page, open DevTools and in the Console run:

[...document.querySelectorAll('script[type="application/ld+json"]')]
  .map(s => JSON.parse(s.textContent))
  .flatMap(d => Array.isArray(d) ? d : (d['@graph'] ?? [d]))
  .map(n => [n['@type'], n['@id'] ?? '', n.offers?.price ?? '', n.aggregateRating?.ratingValue ?? ''])

It returns one row per node on the page: the type, its @id if it has one, the price it claims and the rating it claims. Read it for four things.

  • More than one row of type Product or ProductGroup. That is the duplicate. Note which block each came from; the app's is usually the one without an @id.
  • A price that is not the price on the page. A compare-at price, a price before tax, or the price of a variant other than the selected one all surface here.
  • A currency that is not the market's. If the store sells in several currencies from one URL, Google's guideline is a distinct URL per currency; a single URL whose price changes by visitor is a URL whose price Google cannot verify.
  • An empty rating column next to visible stars. That is the detached rating, and it is the most common result.

Search Console reports the same thing over time rather than for one URL. The merchant listing guide names the two reports that matter — Merchant listings, for pages where shoppers can buy, and Product snippets, for the rest — and both carry the same errors and warnings as the test. The reports' own help page adds two caveats: they count items rather than pages, and they show a sample rather than every URL, so a clean report is necessary but not proof.

One snippet that reads the standard metafields, and the deletion of everything else

The fix is not another block. It is one block that carries everything, sourced from the same places the page sources its visible content, so the guideline about markup matching the page is satisfied by construction.

Create snippets/product-json-ld.liquid:

{%- liquid
  assign current = product.selected_or_first_available_variant
  assign rating = product.metafields.reviews.rating.value
  assign rating_count = product.metafields.reviews.rating_count | default: 0
  assign availability = 'OutOfStock'
  if current.available
    assign availability = 'InStock'
  endif
-%}
<script type="application/ld+json">
{
  "@context": "https://schema.org/",
  "@type": "Product",
  "@id": "{{ shop.url }}{{ product.url }}#product",
  "name": {{ product.title | json }},
  "description": {{ product.description | strip_html | json }},
  "image": [
    {%- for image in product.images limit: 3 -%}
      "https:{{ image | image_url: width: 1600 }}"{% unless forloop.last %},{% endunless %}
    {%- endfor -%}
  ],
  "brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
  {%- if current.sku != blank %}
  "sku": {{ current.sku | json }},
  {%- endif %}
  {%- if current.barcode != blank %}
  "gtin": {{ current.barcode | json }},
  {%- endif %}
  {%- if rating != blank and rating_count > 0 %}
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": {{ rating.rating }},
    "bestRating": {{ rating.scale_max }},
    "ratingCount": {{ rating_count }}
  },
  {%- endif %}
  "offers": {
    "@type": "Offer",
    "url": "{{ shop.url }}{{ current.url }}",
    "price": {{ current.price | divided_by: 100.0 }},
    "priceCurrency": {{ localization.country.currency.iso_code | json }},
    "availability": {{ availability | prepend: 'https://schema.org/' | json }},
    "itemCondition": "https://schema.org/NewCondition"
  }
}
</script>

The choices, because each one is a bug we have fixed on a live store:

  • json on every string. A product title with a quotation mark in it produces invalid JSON, and invalid JSON is no markup at all. The filter escapes it.
  • divided_by: 100.0 on the price. The variant object's price is in the currency's subunit, and Google wants a dot as the decimal separator. The money filters format to the store's locale, which on a European store means a comma, which means an unreadable price.
  • bestRating from the metafield's own scale. Google assumes a five-point scale when none is given; the rating metafield carries its scale with it, so use it.
  • The rating only when the count is above zero, and only if your theme renders the stars block. If your product template has the block switched off, delete the aggregateRating branch rather than ship a rating the reader cannot see.
  • gtin from the barcode field, only when it is set. Google wants the most specific GTIN that applies, in numeric form, and an empty string is worse than nothing.

Then, in the code editor, replace the structured_data line in the product section with {% render 'product-json-ld' %}. Then go to Online Store, Themes, Customize, App embeds, and switch off whichever embed the review app calls its SEO, rich snippet or JSON-LD output. If it has no such toggle, it is injecting from its script at runtime, and the setting lives in the app. Re-run the console line: one row.

What you give up is the ProductGroup the filter emitted for a product with variants. Google's variants guide is the reference for getting it back: each variant needs a unique identifier in its markup, sku or gtin; the group needs one too; and each variant needs a distinct URL that preselects the right image, price and availability, which the ?variant= URL already does. Extending the snippet to a ProductGroup with a hasVariant loop is the afternoon version. The snippet above is the hour version, and it is valid on its own.

Delete the duplicate first; the whole job is an afternoon

In this order, because it is the order in which each step makes the next one measurable:

  1. The duplicate node. An hour, including the test. Nothing else can be measured while two blocks disagree.
  2. The snippet in place of the filter. An hour, and most of it is reading the review app's settings to confirm it writes the standard reviews.rating metafields. If it does not, the theme cannot show its rating either, and that is a reason to be on a different app.
  3. Identifiers. Minutes per product if the barcodes are already in the admin, a data job if they are not. It is the step that turns a product snippet into a shopping knowledge panel candidate, and it is the one most stores never do.
  4. Re-test three URLs, then wait. Request indexing in Search Console and watch the Merchant listings report. Days, not hours, and a single reading is a data point rather than a trend.

That is the entire scope, and it belongs in the AI visibility section of any store audit rather than in a separate project. If you would rather have it found, ranked against everything else the store is leaving on the table and written up, it is section five of the Store Teardown — €750, five days, and the document is yours whatever you decide afterwards.

Own your growth.Start with the teardown.

Start with a Teardown. Read it, then decide whether you want us to fix what it finds.