Home › Blog › 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.
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.
In this guide
- What ProductGroup actually is
- Why AI shopping agents need ProductGroup specifically
- What we found scanning 100 stores
- The four shapes Shopify stores emit (and which is correct)
- How to detect what your store emits in 60 seconds
- The correct shape, written out
- How to fix in cost order
- Five mistakes we keep finding
- What good ProductGroup looks like
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:
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.hasVariant— the array of childProductentries. 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.variesBy— the list of schema.org properties that distinguish variants.colorandsizeare the canonical pair; some catalogs addmaterialorpattern. 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.
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:
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:
- 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.
- 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
WebPageandBreadcrumbList). 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)
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.
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.
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.
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:
"@type": "ProductGroup"with ahasVariantarray — Shape 4. You're good. (Caveat: still validate the array's contents — see mistakes.)"@type": "Product"with anoffersarray — Shape 2 or hybrid. You're getting partial credit. Worth upgrading."@type": "Product"with a singleoffersobject — parent-only Shape 2. Variant queries miss you.- No Product or ProductGroup at all (just
WebPage,BreadcrumbList,Organization) — Shape 1. You're invisible to AI shopping agents on every product query, full stop.
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.
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
| Fix | When to use | Cost |
|---|---|---|
| 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
- 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. - Missing
productGroupID. The store emits ProductGroup with hasVariant but noproductGroupID. Agents fall back to URL-based identity, which works until your URLs change (a re-platform, a slug rename, a country-domain split). Always setproductGroupIDtoproduct.id— it's the most stable identifier Shopify has. - hasVariant entries missing required fields. Each child
ProductinhasVariantneeds at minimumsku,offers.price,offers.availability, and (if applicable) the variant-axis attribute (color,size). We see stores emit justsku+namefor 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. - 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. - 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 ingetStaticPropsor its app-router equivalent, not inuseEffect.
What good ProductGroup looks like
Full credit on signal #2 — the 30-point Product JSON-LD signal — when:
- Every PDP emits exactly one
application/ld+jsonblock whose primary item is@type: "ProductGroup". - The block includes
productGroupID,variesBy,name,brand, andhasVariant. hasVariantcontains one entry per purchasable variant, with@type: "Product",sku,offers.price,offers.availability, the variant-axis fields (color,size), and per-variantimagewhen available.- The whole block parses cleanly — Google's Rich Results Test reports zero errors.
- Single-variant products (no color, no size) emit a flat
{"@type":"Product"}rather than a degenerate ProductGroup of one. ProductGroup with one variant is technically valid but looks broken to some parsers; flat Product is the canonical shape for genuinely single-variant items.
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
- The 15 signals AI shopping agents read — one section per signal; #2 is Product JSON-LD on PDP, the 30-pt floor signal this post covers
- Shopify metafields for AI shopping agents — the eight metafields that move the score, what feeds into the variant attributes inside hasVariant
- Shopify GTIN requirements for AI shopping agents — the per-variant GTIN signal that lives inside each hasVariant entry
- Agentic Storefronts checklist — the 18-item audit, full list grouped by floor vs. ranking-spread
- 100-store DTC leaderboard — the dataset behind the 60% / 18-pt headline figure
- How CatalogScan compares to Profound, Otterly.AI, SE Ranking — when each tool is the right call
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