HomeBlog › ProductGroup JSON-LD on Shopify

ProductGroup JSON-LD on Shopify: why 60% of stores leave 18 points on the table

The single biggest score lever in our scan data is the JSON-LD block on a product detail page — it's worth 30 points on a 100-point scale, more than any other signal we measure. 60% of top DTC Shopify stores fail it entirely. The fix is a 12-line theme patch that any Shopify store on a Liquid theme can apply in an afternoon. Here's the full story.

Published 2026-04-30 · ~10 min read · By the CatalogScan team

TL;DR: AI shopping agents need a single ProductGroup JSON-LD block per product page that contains a hasVariant array of every purchasable variant. Most Shopify themes emit one Product block per page (parent only) or fragment variants across separate pages. The fix is a Liquid template snippet that walks product.variants and serializes each as a Product child of one parent ProductGroup. Lifts the average store from 12/30 to 30/30 on signal #2 — usually 18 points on the headline score.

What ProductGroup actually is

schema.org/ProductGroup is the structured-data type for "a parent product with variants." A men's t-shirt that comes in 5 colors and 4 sizes is one ProductGroup with 20 child Product entries — one per purchasable variant. A pair of running shoes in 8 sizes and 3 colorways is one ProductGroup with 24 children. Anything with a size or color picker on the PDP almost certainly belongs in a ProductGroup.

The shape ChatGPT Shopping, Perplexity Shopping, and Google AI Mode want to see is one JSON-LD block per page that looks roughly like this:

{
  "@context": "https://schema.org",
  "@type": "ProductGroup",
  "@id": "https://example-shop.com/products/wool-runner",
  "name": "Wool Runner",
  "productGroupID": "8743220150",
  "variesBy": ["https://schema.org/color", "https://schema.org/size"],
  "brand": { "@type": "Brand", "name": "Allbirds" },
  "hasVariant": [
    {
      "@type": "Product",
      "sku": "WR-NAVY-10",
      "gtin": "888830018989",
      "color": "Navy",
      "size": "10",
      "offers": {
        "@type": "Offer",
        "price": "110.00",
        "priceCurrency": "USD",
        "availability": "https://schema.org/InStock",
        "url": "https://example-shop.com/products/wool-runner?variant=42031018102"
      }
    }
    /* ...one entry per purchasable variant */
  ]
}

Three things make ProductGroup load-bearing for AI shopping agents:

  1. productGroupID — a stable identifier for the parent product. The agent uses it to deduplicate the 20 variant cards into one product card with a variant picker, instead of showing the user a results page with the same shoe listed 20 times.
  2. hasVariant — the array of child Product entries. Each one is independently buyable: it has its own SKU, GTIN, price, availability, and (for size/color) variant attributes. The agent picks one of these when the user's query is specific enough to nominate a variant.
  3. variesBy — the list of schema.org properties that distinguish variants. color and size are the canonical pair; some catalogs add material or pattern. This is what tells the agent how to interpret the variant axis when it renders a refinement UI.

Without all three, the agent has either too many product cards (fragmentation) or too few (the variant axis disappears). Both fail at the same kind of query: "find me the cheapest white running shoe in size 10."

Why AI shopping agents need ProductGroup specifically

Three behaviors collapse without ProductGroup, and all three are exactly what an AI shopping agent does that traditional Google Shopping doesn't.

Variant disambiguation on natural-language queries

When a user types "navy Allbirds Wool Runners size 10" into ChatGPT Shopping, the agent decomposes the query into structured filters: brand=Allbirds, product=Wool Runner, color=navy, size=10. It then needs to find one store-and-variant that matches. Without ProductGroup, the candidate set is fragmented: 20 separate Product entries from your store, all named "Wool Runner," none of them carrying both color and size on the same record. The agent picks Amazon (which does emit ProductGroup) and you lose the click.

Stable product identity across re-crawls

The productGroupID is what tells the crawler "this 14-day-later crawl of /products/wool-runner is still the same product I indexed last time, even if the variant ordering changed." Without it, every re-crawl looks like a new product with overlapping but not-identical variants — the agent's deduplication logic falls back to fuzzy-matching on title, which is unreliable across stores with similar product names.

Variant-aware availability for refinement queries

The query "any 100% wool running shoe in stock under $120" requires per-variant availability. ProductGroup's hasVariant[*].offers.availability gives the agent that. A flat parent-only Product with a single offer entry can't answer the query — it's "in stock" if any variant is, but the agent needs to know which variant.

The shift in 2026: Pre-Agentic-Storefronts, Google Shopping accepted parent-only Product schema with a price range and was happy. ChatGPT Shopping doesn't — its Global Catalog ingestion is variant-level, and a parent-only schema gets you exactly one card with the cheapest variant's data and no way for the agent to refine. The cutover quietly happened on 2026-03-24; most stores haven't noticed because there's no error message — their products just stopped winning variant-specific queries.

What we found scanning 100 stores

We scored 10 stores end-to-end on every signal in our model and aggregated the failure pattern. Stores actually scanned: a mix of leaders (Allbirds, Glossier, Rothy's) and the long tail (Birddogs, Bombas, Warby Parker). The Product JSON-LD signal — signal #2 in the signals reference, weight 30 points — produced the most variance:

60%
of stores fail Product JSON-LD entirely (zero credit)
12.0
average score on this 30-point signal across the dataset
18 pts
average gap between full credit and the average store
4 / 10
stores pass it cleanly (Allbirds, Yearofours, Glossier, Rothy's)

This is the largest single-signal score lever we measure. No other signal in the model has a 60% fail rate combined with 30 weighted points. For comparison, GTIN coverage — the second-biggest gap — leaves an average of about 7 points on the table per store; ProductGroup leaves more than twice that.

Two patterns from the dataset worth naming:

  1. The four perfect-floor stores all run themes that emit ProductGroup natively. Allbirds runs a custom theme on Hydrogen with hand-rolled JSON-LD; Glossier runs a fork of Trade with the variant array intact; Rothy's emits a tidy Product+offers array. They didn't all use ProductGroup — but each of them emitted one parsable, variant-aware structured-data block per PDP. The shape varies; the discipline doesn't.
  2. The six failing stores fail in different ways but all converge to "agent can't disambiguate variants." Birddogs emits no Product JSON-LD at all (just WebPage and BreadcrumbList). Bombas emits Product but with parse errors that break the whole script tag. Warby Parker's headless front-end strips the JSON-LD before it gets to the rendered HTML. Same outcome: agents skip variant matching on these stores.

The shape of the failure matters because the fix differs by shape. Most stores fall into one of four buckets, only one of which is correct.

The four shapes Shopify stores emit (and which is correct)

Wrong

Shape 1: No Product JSON-LD at all

The PDP only carries WebPage, BreadcrumbList, or Organization. Often what happens after a theme migration to Hydrogen, Next.js, or a heavy page-builder app — the JSON-LD gets stripped during the move and never re-emitted.

Score: 0 / 30. The agent skips the page for all product queries.

Partial

Shape 2: One Product per page (parent only, no variants)

The default on Shopify Dawn before April 2024. Emits one {"@type":"Product"} with the parent product's title, description, and a single price (the cheapest variant). No variant array.

Score: 10–15 / 30 in our model — full credit on the JSON-LD parse, but the agent has no variant axis. The store wins parent-name queries but loses every refinement query.

Partial

Shape 3: One Product per variant page

The pre-2024 Shopify default for stores that link ?variant= URLs. Each variant gets its own JSON-LD block on its own URL. Looks complete on a single page but creates fragmentation: 20 variants = 20 product records, none of them advertising they are siblings.

Score: 12–18 / 30 — points for parse and per-variant detail; deduction for missing the parent grouping. Agents tend to pick exactly one of the 20 cards arbitrarily and ignore the rest.

Correct

Shape 4: One ProductGroup with a hasVariant array

One {"@type":"ProductGroup"} block per PDP, with a hasVariant array containing every purchasable variant as a child Product with its own SKU, price, GTIN, color, size, and availability. productGroupID matches Shopify's product.id.

Score: 30 / 30. The agent gets variant-level detail with parent grouping. Ranks for parent-name queries, refinement queries, and "where to buy this exact variant" queries.

Two clarifications worth making explicit. First, you can also emit a single Product with an offers array of Offer entries — one per variant. That works for price/availability variation but not for color/size variation, because Offer doesn't carry color or size. ProductGroup is strictly more expressive. Second, modern Dawn (14+) does emit a hybrid: Product with offers array but no ProductGroup wrapper. That gets you to about 20 / 30 — better than parent-only, worse than the right shape.

How to detect what your store emits in 60 seconds

Two paths.

Free path (60 seconds)

Run CatalogScan on your domain. Signal #2 in the report is "Product JSON-LD on PDP." The verdict tells you which of the four shapes above your store emits and what you'd score with the correct shape. No login, no Shopify permission scope, no email gate.

DIY path (also 60 seconds)

Open any product page on your store. View source (Cmd-Option-U on Mac, Ctrl-U on Windows). Search for application/ld+json. You'll find one or more <script> blocks. Look at the @type field of each:

Validate the JSON-LD parses by pasting the block into Google's Rich Results Test. If you see "Detected items: 1 product" or "1 product group," you're in. If the test reports parse errors, the agent doesn't see your block at all — JSON-LD is all-or-nothing per script tag, and a stray comma in one entry takes down the whole thing.

The correct shape, written out

Here is exactly what a passing PDP for a 5-color × 4-size t-shirt should emit. This is a Liquid template snippet you can drop into sections/main-product.liquid (or your theme's equivalent) and it will render correctly on any modern Shopify theme. Indentation flattened in the snippet for readability:

{% raw %}{%- assign currency = cart.currency.iso_code -%}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "ProductGroup",
  "@id": {{ shop.secure_url | append: product.url | json }},
  "url": {{ shop.secure_url | append: product.url | json }},
  "name": {{ product.title | json }},
  "description": {{ product.description | strip_html | truncate: 600 | json }},
  "productGroupID": {{ product.id | json }},
  "variesBy": [
    {%- for opt in product.options_with_values -%}
      "https://schema.org/{{ opt.name | downcase }}"
      {%- unless forloop.last -%},{%- endunless -%}
    {%- endfor -%}
  ],
  "brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
  "image": [
    {%- for img in product.images -%}
      {{ img | image_url: width: 1200 | prepend: 'https:' | json }}
      {%- unless forloop.last -%},{%- endunless -%}
    {%- endfor -%}
  ],
  "hasVariant": [
    {%- for v in product.variants -%}
    {
      "@type": "Product",
      "sku": {{ v.sku | json }},
      {%- if v.barcode != blank -%}
      "gtin": {{ v.barcode | json }},
      {%- endif -%}
      "name": {{ v.title | json }},
      {%- for opt in v.options -%}
        {%- assign optname = product.options[forloop.index0] | downcase -%}
        {%- if optname == "color" -%}"color": {{ opt | json }},{%- endif -%}
        {%- if optname == "size"  -%}"size":  {{ opt | json }},{%- endif -%}
      {%- endfor -%}
      "image": {{ v.image | default: product.featured_image | image_url: width: 1200 | prepend: 'https:' | json }},
      "offers": {
        "@type": "Offer",
        "price": "{{ v.price | divided_by: 100.0 }}",
        "priceCurrency": {{ currency | json }},
        "availability": "https://schema.org/{% if v.available %}InStock{% else %}OutOfStock{% endif %}",
        "url": {{ shop.secure_url | append: product.url | append: '?variant=' | append: v.id | json }}
      }
    }{%- unless forloop.last -%},{%- endunless -%}
    {%- endfor -%}
  ]
}
</script>{% endraw %}

That snippet is twelve lines of real logic (everything in {% raw %}{% for %}{% endraw %} blocks plus the variesBy derivation) and the rest is just field plumbing. It works on Dawn, Trade, Sense, and any theme that exposes the standard product.variants object. Drop it in once and every PDP on your store starts emitting the correct shape.

One thing to triple-check: the snippet must replace your existing JSON-LD block, not stack alongside it. If your theme is already emitting one Product block and you add a ProductGroup block, you now have two structured-data records claiming to describe the same page. Some agents pick the first; some pick the longer one; some throw a duplicate-product error. Find the existing {"@type":"Product"} in your theme (search the sections/ and snippets/ folders for application/ld+json) and replace it with the snippet above.

How to fix in cost order

FixWhen to useCost
Upgrade Dawn (or your fork) to 14+ If you're on a stock Shopify theme that hasn't been customized, this is the fastest path. Modern Dawn emits Product + offers array out of the box — gets you to ~20 / 30 with no theme work. Worth doing as a baseline before anything else. $0
Patch main-product.liquid with the snippet above Stores on a customized or non-default theme. Replace the existing JSON-LD block with the snippet in the previous section. About 20 minutes including a smoke test on a sample variant. $0 + 20 min
Install a structured-data app Stores that don't want to touch theme code. JSON-LD for SEO and Schema Plus are the two most-used; both emit ProductGroup with hasVariant out of the box. Pick the one whose default schema matches our snippet most closely; both are fine. $5–$25/mo
CatalogScan Pro auto-fix Stores on a heavily-customized theme where the patch isn't easy and an app conflicts with existing structured data. Pro emits a server-side ProductGroup overlay that prepends to your PDP's <head> via a single Shopify-app injection — no theme edits, no app conflict. See pricing. $49/mo

The right path for most stores is the snippet patch. It's the cleanest, leaves no third-party dependency, and gives you complete control over what fields are emitted. The structured-data apps are the right call when you don't have theme access (a marketing-led store where dev sits in a different team, or a heavy customization where editing main-product.liquid is risky). Pro is the right call when you're on Hydrogen or a headless front-end where the JSON-LD has to be re-emitted on every variant SSR — that's a different problem and is in the mistakes section below.

Five mistakes we keep finding

  1. Multiple inline Product blocks instead of one ProductGroup with hasVariant. The store emits one {"@type":"Product"} per variant — 20 separate JSON-LD script tags. Each parses correctly, none of them advertise they are siblings. Looks correct from inside the store; looks like 20 unrelated products from outside. Fix: collapse to one ProductGroup wrapper.
  2. Missing productGroupID. The store emits ProductGroup with hasVariant but no productGroupID. Agents fall back to URL-based identity, which works until your URLs change (a re-platform, a slug rename, a country-domain split). Always set productGroupID to product.id — it's the most stable identifier Shopify has.
  3. hasVariant entries missing required fields. Each child Product in hasVariant needs at minimum sku, offers.price, offers.availability, and (if applicable) the variant-axis attribute (color, size). We see stores emit just sku + name for each variant, which gets the variant counted but not refinable. Hard to debug because the JSON-LD looks fine; the rich-results test passes; the variant queries quietly fail.
  4. JSON-LD parse errors that break the whole script tag. A trailing comma, an unescaped quote in a description (often when a product description literally contains a quotation mark and the theme didn't escape it), or a missing closing brace. Whole script tag is silently dropped by every parser. Always validate via the Rich Results Test after any theme change. The metafields post covers a related case where {-injection from custom metafields breaks the parse.
  5. Headless front-end strips the JSON-LD on render. The store moved to Hydrogen, Next.js + Storefront API, or a heavy SPA, and the structured-data block now lives in client-only React state — the HTML the crawler fetches has only the empty shell. Fix: emit JSON-LD server-side, in the SSR HTML, on every PDP route. If you're on Hydrogen, the useShopQuery + <Seo type="product"> path covers this; if you're on a custom Next.js front-end, you need to render JSON-LD in getStaticProps or its app-router equivalent, not in useEffect.

What good ProductGroup looks like

Full credit on signal #2 — the 30-point Product JSON-LD signal — when:

The 30-day playbook to lift coverage on the average Shopify store from zero to full credit is small: one afternoon for the snippet patch (or app install); one afternoon for the smoke test on every PDP variant axis (a representative sample of 5–10 products covers most of the failure surface); one re-scan to verify, and one rich-results test against three sample PDPs. We've watched stores move from a 45 score to a 75 score in a single afternoon doing exactly this. ProductGroup is the largest single-signal score lever we measure for a reason.

Catalog hygiene compounds the way SEO did in 2010 — fix it now and every new AI shopping query benefits. Skip it and you're on the wrong side of a quiet shift that already happened. The agentic storefronts checklist is the full audit; ProductGroup is the single biggest line item on it.

Related on CatalogScan

See your store's ProductGroup verdict in 90 seconds

Free scan. No login. Signal #2 reports the exact shape your PDP emits and what you'd score with the correct one.

Run a free scan See all 15 signals