Skip to main content
AI GuidesShopify-native tactics

Surfacing Shopify variants to AI engines

AI retrievers often cite a product correctly but return wrong variant details — available sizes, colors, or specs. The root cause is almost always variant data that the page hides behind JavaScript or that schema never emits. Here is the fix.

Samir Bhattacharya with Hiren Bhuva

Shopify GEO Engineer

10 min
schema-stack.svg
Surfacing Shopify variants to AI enginesProduct"@type": "Product"validOffer"@type": "Offer"validReview"aggregateRating": 4.8validFAQPage"@type": "FAQPage"validBreadcrumbList"itemListElement": […]valid

Why AI engines miss Shopify variants — three structural reasons

Variant data commonly lives behind JavaScript pickers, not in server-rendered HTML. Product schema omits variants. Feeds sometimes collapse variants into a single product row.

Shopify handles product variants well in its admin and storefront UI — the variant picker on a modern Dawn theme is a clean dropdown experience for shoppers. The problem is that the clean shopper experience often comes at the expense of machine-readable variant data. Three structural patterns cause AI retrievers to miss, misread, or invent variant information on Shopify products.

  1. 1JavaScript-only variant pickers. Some themes build the variant dropdown client-side from a JSON blob. Retrievers that do not execute JavaScript (which is most of them) see only the first variant's content in the HTML, and have no visibility into the other variants at all.
  2. 2Product schema emits only the selected variant. Default Shopify Product schema often represents only the currently-selected variant's offer, omitting the full variant matrix. Retrievers have no way to know that the product comes in six sizes and four colours.
  3. 3Feeds collapse variants into a parent product. Some merchant-feed integrations export only the parent product record, not the variants, which means ChatGPT Shopping, Google AI Shopping, and Bing Shopping cannot show variant-specific availability or pricing.

41%

of Shopify PDPs in our audit panel have variant data that is invisible to non-JS retrievers

Surfient crawl audit, 847 Shopify stores, Q1 2026. Measured by comparing the variant options visible in server-rendered HTML against the options present in the theme's product JSON payload.

The consequence is that retrievers sometimes 'hallucinate' variants — inferring that a men's shirt probably comes in S, M, L, XL when the actual catalog only stocks M, L, XL, XXL. Hallucinations usually align with common category expectations, so customers get mildly wrong answers that look reasonable but erode trust when they try to buy.

step-flow.svgInfographic
The four-step arc this guide walks through — each numbered card maps to a section below.01AI engines missShopify variants —three structural02The ProductGroup +Product patternfor variant schema03The Shopify Liquidimplementation forProductGroup04Visible HTMLvariant tables —theSEQUENCE · STEP 1 → STEP 4
Figure · step flowThe four-step arc this guide walks through — each numbered card maps to a section below.

The ProductGroup + Product pattern for variant schema

ProductGroup represents the overall style; nested Product entries represent each variant with its own SKU, price, and availability.

Schema.org's ProductGroup type is designed specifically for the case Shopify variants represent — a single product style that comes in multiple purchasable variants. ProductGroup describes what varies (size, colour, spec) via the variesBy property, and the individual variants are separate Product nodes linked via hasVariant. This is the structure Google Shopping, Bing Shopping, ChatGPT Shopping, and Perplexity all understand and consume.

{
  "@context": "https://schema.org",
  "@type": "ProductGroup",
  "name": "Carter Chelsea Boot",
  "productGroupID": "chelsea-boot-carter",
  "description": "Handmade leather Chelsea boot in three colours and seven sizes.",
  "variesBy": ["https://schema.org/color", "https://schema.org/size"],
  "brand": {
    "@type": "Brand",
    "name": "Example Footwear"
  },
  "hasVariant": [
    {
      "@type": "Product",
      "sku": "CHL-CARTER-BLACK-42",
      "name": "Carter Chelsea Boot - Black, 42",
      "color": "Black",
      "size": "42",
      "offers": {
        "@type": "Offer",
        "price": "240.00",
        "priceCurrency": "GBP",
        "availability": "https://schema.org/InStock",
        "url": "https://example.com/products/carter-chelsea-boot?variant=12345"
      }
    },
    {
      "@type": "Product",
      "sku": "CHL-CARTER-BROWN-42",
      "name": "Carter Chelsea Boot - Brown, 42",
      "color": "Brown",
      "size": "42",
      "offers": {
        "@type": "Offer",
        "price": "240.00",
        "priceCurrency": "GBP",
        "availability": "https://schema.org/InStock"
      }
    }
  ]
}
variesBy
Array of schema.org properties the variants differ on — typically size, color, material, pattern. Tells retrievers which dimensions matter for this product.
hasVariant
Array of Product nodes, one per variant. Each carries its own sku, the variesBy values (color, size), and its own Offer with price and availability.
Offer.url per variant
Each variant's offer can include a variant-specific URL (/products/slug?variant=12345). Retrievers use this for deep-linking to the exact variant the shopper wants.
productGroupID
Stable identifier for the style as a whole. Useful for retrievers to group variants across feed and schema contexts.

The Shopify Liquid implementation for ProductGroup schema

Iterate product.variants, emit one Product node per variant with its specific attributes and offer.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "ProductGroup",
  "name": {{ product.title | json }},
  "productGroupID": {{ product.handle | json }},
  "description": {{ product.description | strip_html | truncate: 300 | json }},
  "url": "{{ shop.url }}{{ product.url }}",
  "variesBy": [
    {%- for option in product.options -%}
      "https://schema.org/{{ option | downcase }}"{%- unless forloop.last -%},{%- endunless -%}
    {%- endfor -%}
  ],
  "brand": {
    "@type": "Brand",
    "name": {{ product.vendor | json }}
  },
  "hasVariant": [
    {%- for v in product.variants -%}
    {
      "@type": "Product",
      "sku": {{ v.sku | json }},
      "name": {{ v.title | json }},
      {%- for opt_name in product.options -%}
        {%- assign idx = forloop.index0 -%}
        {%- assign opt_value = v.options[idx] -%}
        "{{ opt_name | downcase }}": {{ opt_value | json }},
      {%- endfor -%}
      "offers": {
        "@type": "Offer",
        "price": {{ v.price | money_without_currency | json }},
        "priceCurrency": {{ cart.currency.iso_code | json }},
        "availability": {%- if v.available -%}"https://schema.org/InStock"{%- else -%}"https://schema.org/OutOfStock"{%- endif -%},
        "url": "{{ shop.url }}{{ product.url }}?variant={{ v.id }}"
      }
    }{%- unless forloop.last -%},{%- endunless -%}
    {%- endfor -%}
  ]
}
</script>
  • Iterate product.variants — every purchasable variant gets its own Product node.
  • Map product.options to the variesBy array — whatever the merchant named (Size, Color, Material) becomes a schema property.
  • Per-variant name combines product title with variant title for disambiguation.
  • Per-variant URL uses the ?variant={{ v.id }} parameter so deep links resolve to the correct selection.
  • Availability is per-variant — reflects actual stock status per SKU, which retrievers use to filter for in-stock options.

Visible HTML variant tables — the server-rendered fallback that retrievers prefer

A schema-only approach works for AI retrievers that parse JSON-LD. A visible variant table works for every retriever, including the simpler crawlers.

Correctly-emitted ProductGroup schema handles the majority of AI retrievers, but the simplest and most robust signal is a visible HTML variant table on the page — server-rendered, not JavaScript-injected. This has the double benefit of being crawlable by every retriever regardless of its schema parsing sophistication, and of satisfying the schema-mirror rule (schema content should be visible on the page in some form).

{%- if product.variants.size > 1 -%}
  <section class="product-variants" aria-label="Available variants">
    <h3>Available variants</h3>
    <table>
      <thead>
        <tr>
          {%- for option in product.options -%}
            <th>{{ option }}</th>
          {%- endfor -%}
          <th>Price</th>
          <th>Availability</th>
        </tr>
      </thead>
      <tbody>
        {%- for v in product.variants -%}
        <tr>
          {%- for opt_value in v.options -%}
            <td>{{ opt_value }}</td>
          {%- endfor -%}
          <td>{{ v.price | money }}</td>
          <td>{%- if v.available -%}In stock{%- else -%}Out of stock{%- endif -%}</td>
        </tr>
        {%- endfor -%}
      </tbody>
    </table>
  </section>
{%- endif -%}
  • Rendered server-side in Liquid, visible without JavaScript.
  • Mirrors the data the schema emits — same variants, same prices, same stock.
  • Accessible by default (proper table markup, header rows, aria-label).
  • Visually unobtrusive with light styling — most merchants collapse it into an expandable section for users while keeping it always-rendered for retrievers.
  • Hidden via CSS (display:none) is a bad idea — retrievers and Google both downweight hidden content. Use aria-expanded accordion patterns that are accessible and indexable.

Making variants visible in merchant feeds (Google, Bing, ACP)

Feeds need one row per variant, not one row per product. Verify the feed and fix the mapping if variants are being collapsed.

Merchant feeds (Google Merchant Center, Bing Merchant Center, the Agentic Commerce Protocol feed) are another surface where variant data reaches retrievers — and another place where default Shopify settings sometimes get it wrong. The feed specification for all three requires one row per variant (identified by unique SKU or GTIN), with the parent product linked via item_group_id. Some feed integrations collapse all variants into a single parent row, which means retrievers see only the default variant and miss the rest.

One row per variant
Every purchasable SKU gets its own feed row with its own id, price, availability, and variant-identifying attributes (size, color).
item_group_id
Links all variant rows of a single product together. Usually the product's handle or a stable product ID.
Variant-identifying attributes
size, color, material, pattern. One column per variant dimension, populated on every row even if the dimension doesn't vary for a given product.
Variant-specific URL
The link column should point to the variant-specific URL (?variant=12345) so clicks land on the exact variant the shopper selected.
Variant-specific GTIN
If you have GTINs per variant (most apparel brands do), each row carries the correct GTIN for that variant. Reusing a single parent GTIN across variants causes feed disapprovals.

Six common variant mistakes — and how to spot them

JS-only pickers, reusing parent GTIN, wrong availability per variant, and more. Each has a diagnostic.

  1. 1JavaScript-only variant picker — load page with JS disabled, check whether variants are visible. Fix: server-render a variant table as fallback.
  2. 2Product schema with a single variant's offer — check JSON-LD with Rich Results Test; look for hasVariant array. Fix: switch to ProductGroup with hasVariant.
  3. 3Parent GTIN reused on all variants — each variant should have its own GTIN if you have them. Fix: set GTIN per variant in admin, render per-variant.
  4. 4Availability set on the product, not per variant — retrievers then assume all variants are in stock. Fix: set availability per variant in schema and feed.
  5. 5Feed collapses variants — feed has one row per product, not per variant. Fix: set feed integration to variant-level.
  6. 6Variant URLs that don't deep-link — all variants share the same URL with no ?variant= parameter. Retrievers can't surface the specific variant. Fix: use product.url with variant.id in schema and feed.
The gap between 'my product is cited in ChatGPT' and 'my product is cited correctly with the right variants in stock' is exactly the variant-surfacing work. Most Shopify stores are 70% of the way there already and just need the last 30%.
Samir Bhattacharya, Shopify GEO Engineer, Surfient

Frequently asked questions

6

Pulled from the questions merchants ask us most often in advisory calls. Crawlers see these as FAQPage schema — the answers here match what appears in AI citations.

  • Every variant should be addressable via a variant-specific URL parameter (?variant=12345), but it does not need a separate /products/<slug-colour-size> URL. Shopify's default model — one product URL with ?variant= for deep links — is correct and what retrievers expect. Creating separate URLs per variant adds complexity without benefit and fragments the product's citation authority across multiple URLs.

Free · 5 minutes · no signup

Ready to see your store's GEO score?

Run a free Surfient audit and see exactly what ChatGPT, Perplexity, Claude, Gemini, and Google AI Overviews are missing about your store — signal family by signal family.

0

GEO score

Engine readiness

0

Technical indexing

0

Content fit

0

Live example — your number is ready in about 90 seconds.

Keep reading

Browse all AI Guides