When a shopper uploads a photo of a sage green jacket to ChatGPT or Google Lens asking where to buy it, multimodal AI models parse indexed structured data to locate an exact visual match. Pendium data shows that missing variant-level image mapping is a primary reason Shopify merchants lose visual AI search traffic to competitors. To fix this, developers must bypass Shopify's default Liquid filters and structure their JSON-LD using schema.org/ProductGroup for the parent entity, nest individual Product objects for each variant SKU, and attach a specific MediaObject to each child record. This explicit hierarchy gives engines like Google Lens and ChatGPT the exact visual proof needed to map customer photos to the specific variant ready for purchase.
The default Shopify schema trap
Most Shopify themes rely on a single Liquid tag—{{ product | structured_data }}—to generate schema markup. While this filter produces basic JSON-LD that satisfies older search algorithms, it collapses multi-variant catalogs into a single flat entity. In Ecommerce Schema: Your Structured Data Guide for 2026, Shopify highlighted that AI-driven traffic grew eight times year-over-year in 2025, while AI-driven orders grew 15 times. Yet the default Liquid schema engine has not kept pace with how multimodal search agents index visual assets.
The native filter usually emits a single parent entity with an offers array attached directly to that root. When a product comes in four colors, the schema marks up four prices, but it links only the primary featured image. If your main product photo shows a black jacket, the visual search engine associates that black image with every single variant SKU. When an AI shopping assistant evaluates a user prompt for a sage green jacket, your product listing lacks the machine-readable visual connection to qualify as a relevant match.
Shopify's internal data architecture adds to the problem. In the store backend, image management sits separated between the parent product record and variant options. As developers documented in a discussion on ProductVariant.media, fetching variant images through GraphQL requires querying a different connection than querying standard product media. Liquid themes face a similar friction: developers often pull from product.featured_image instead of writing the conditional loops needed to extract variant.image. The default output saves theme execution time, but it starves AI engines of SKU-specific imagery. For brands building an AI visibility strategy for DTC brands, this omission removes their catalog from multimodal search results entirely.
How AI parsers read duplicate IDs
The failure deepens when themes attempt automated variant generation. A known bug outlined in the Shopify community thread on the new structured_data filter for product schema markup reveals that native automation often generates identical @id attributes across child variants. When multiple objects share an identical URI identifier, search engines treat them as duplicate entries for the exact same entity.
{
"@context": "https://schema.org/",
"@type": "ProductGroup",
"@id": "https://example.com/products/field-jacket#product",
"hasVariant": [
{
"@type": "Product",
"@id": "https://example.com/products/field-jacket#product",
"name": "Field Jacket - Black"
},
{
"@type": "Product",
"@id": "https://example.com/products/field-jacket#product",
"name": "Field Jacket - Sage Green"
}
]
}
When an AI parser encounters identical @id values, it cannot differentiate the sage green variant from the black one. Most parsers simply preserve the first entity and drop subsequent duplicates. If you want to prevent AI engines from discarding your catalog, read our guide on how to fix duplicate Shopify JSON-LD before AI engines drop your products. Resolving this requires building an explicit schema hierarchy that gives each SKU its own distinct entity graph.
| Attribute | Default Shopify Schema | AI-Ready Variant Schema |
|---|---|---|
| Root Type | Single Product with flat offers | ProductGroup containing distinct child records |
| Variant Entity | Plain Offer object without image data | Full Product entity nested under hasVariant |
Entity @id | Identical or inherited root URL | Unique URI per SKU (e.g., #variant-123456) |
| Image Node | Single product.featured_image URL | Variant-specific MediaObject or ImageObject |
| AI Retrieval | Matches only default parent photo | Matches visual searches for every colorway |
Build the ProductGroup hierarchy
To fix variant indexing, you must adopt the structured data standards that Google introduced for product variants. As documented in the 2026 Shopify Structured Data Checklist for Product Variants, search engines look for a clear parent-child structure. The root object represents the broad product concept, while child nodes define the specific variations available for order.
Using the ProductGroup class establishes this relationship. It tells multimodal scrapers that your jacket is not twenty disconnected products, nor is it a single flat item. It is a defined family of items that vary along explicit dimensions such as color, size, and material. Pendium monitors how tools like ChatGPT, Claude, and Google AI Overviews read these product families across major e-commerce platforms.
Defining the parent product
The top of your JSON-LD script must instantiate the ProductGroup and define the attributes that separate each variation. You need three specific schema properties to establish this foundation:
productGroupID: A stable, non-changing identifier for the parent item, typically mapped toproduct.id.variesBy: An array of schema properties that indicate how the variants differ, such ashttps://schema.org/colororhttps://schema.org/size.@id: A canonical URI that anchors the product family, usually the clean product URL appended with#product-group.
In your Liquid template, you construct this parent node using native Shopify objects:
{
"@context": "https://schema.org/",
"@type": "ProductGroup",
"@id": "{{ shop.url }}{{ product.url }}#product-group",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | json }},
"url": "{{ shop.url }}{{ product.url }}",
"productGroupID": "{{ product.id }}",
"variesBy": [
{%- if product.options contains 'Color' or product.options contains 'Colour' -%}
"https://schema.org/color"{%- if product.options contains 'Size' -%},{%- endif -%}
{%- endif -%}
{%- if product.options contains 'Size' -%}
"https://schema.org/size"
{%- endif -%}
],
"brand": {
"@type": "Brand",
"name": {{ product.vendor | json }}
},
"hasVariant": [
{%- comment -%} Child variants will be nested here {%- endcomment -%}
]
}
Declaring the variants
Inside the hasVariant array, every purchasable SKU must appear as its own full Product entity. This is where most Shopify implementations fail. Rather than outputting bare Offer objects, each variant must retain its identity as a physical item with unique dimensions, images, and identifiers.
To avoid duplicate entity errors, generate a distinct @id for every entry in the loop. Appending #variant-{{ variant.id }} to the base product URL creates a permanent, resolvable URI for that specific SKU. This separation allows AI models to isolate the sage green medium jacket from the navy blue extra-large jacket during extraction.
{%- for variant in product.variants -%}
{
"@type": "Product",
"@id": "{{ shop.url }}{{ variant.url }}#variant-{{ variant.id }}",
"sku": {{ variant.sku | default: variant.id | json }},
"name": {{ variant.title | prepend: ' - ' | prepend: product.title | json }},
"url": "{{ shop.url }}{{ variant.url }}"
{%- comment -%} Variant details and media mapped below {%- endcomment -%}
}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
Map the MediaObject to the exact SKU
Once the parent-child hierarchy exists, you must connect the visual data layer. Multimodal AI search engines do not rely solely on text descriptions. Models process visual embeddings from your product imagery and cross-reference them against the schema data attached to that specific URL.
If your variant schema does not link directly to a distinct image, AI engines drop back to the parent image. To make visual search work, you must map a dedicated MediaObject or ImageObject to the variant's image property.
The required fields for mapping variant imagery include:
- The specific variant image URL formatted with Shopify's
image_urlfilter at an optimal resolution (1200 pixels or higher). - The exact SKU or GTIN barcode (
gtin12,gtin13, orgtin14) representing the specific physical item. - An individual
Offernode defining the price, currency, availability status, and direct variant purchase URL. - The descriptive color and size strings mapped to schema properties like
colorandsize.
Instead of referencing product.featured_image, the Liquid loop must look for variant.image. If a merchant has assigned a specific photo to a color variant in the Shopify admin, variant.image targets that asset directly.
{%- assign variant_image = variant.image | default: product.featured_image -%}
"image": {
"@type": "ImageObject",
"@id": "{{ variant_image | image_url: width: 1200 | prepend: 'https:' }}#image",
"url": "{{ variant_image | image_url: width: 1200 | prepend: 'https:' }}",
"contentUrl": "{{ variant_image | image_url: width: 1200 | prepend: 'https:' }}",
"caption": {{ variant.title | prepend: ' - ' | prepend: product.title | json }},
"width": "1200",
"height": "1200"
}
Using a full ImageObject instead of a raw string URL provides clear metadata to search bots. It gives the parser explicit dimensions and an anchor URI that connects the pixels to the SKU.
Complete Liquid implementation
Below is the full custom Liquid snippet to place in a new snippet file (e.g., snippets/ai-product-group-schema.liquid). Render this snippet inside your layout/theme.liquid or templates/product.json template, and remove the native {{ product | structured_data }} tag to prevent conflicts.
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "ProductGroup",
"@id": "{{ shop.url }}{{ product.url }}#product-group",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | truncatewords: 50 | json }},
"url": "{{ shop.url }}{{ product.url }}",
"productGroupID": "{{ product.id }}",
"brand": {
"@type": "Brand",
"name": {{ product.vendor | json }}
},
"variesBy": [
{%- assign has_options = false -%}
{%- for option in product.options_with_values -%}
{%- assign downcase_name = option.name | downcase -%}
{%- if downcase_name contains 'color' or downcase_name contains 'colour' -%}
{%- if has_options -%},{%- endif -%}"https://schema.org/color"
{%- assign has_options = true -%}
{%- elsif downcase_name contains 'size' -%}
{%- if has_options -%},{%- endif -%}"https://schema.org/size"
{%- assign has_options = true -%}
{%- endif -%}
{%- endfor -%}
],
"hasVariant": [
{%- for variant in product.variants -%}
{%- assign variant_image = variant.image | default: product.featured_image -%}
{
"@type": "Product",
"@id": "{{ shop.url }}{{ variant.url }}#variant-{{ variant.id }}",
"name": {{ variant.title | prepend: ' - ' | prepend: product.title | json }},
"sku": {{ variant.sku | default: variant.id | json }},
{%- if variant.barcode.size == 12 -%}
"gtin12": "{{ variant.barcode }}",
{%- elsif variant.barcode.size == 13 -%}
"gtin13": "{{ variant.barcode }}",
{%- elsif variant.barcode.size == 14 -%}
"gtin14": "{{ variant.barcode }}",
{%- endif -%}
{%- for option in product.options_with_values -%}
{%- assign opt_name = option.name | downcase -%}
{%- assign opt_index = 'option' | append: forloop.index -%}
{%- if opt_name contains 'color' or opt_name contains 'colour' -%}
"color": {{ variant[opt_index] | json }},
{%- elsif opt_name contains 'size' -%}
"size": {{ variant[opt_index] | json }},
{%- endif -%}
{%- endfor -%}
"image": {
"@type": "ImageObject",
"@id": "{{ variant_image | image_url: width: 1200 | prepend: 'https:' }}#image",
"url": "{{ variant_image | image_url: width: 1200 | prepend: 'https:' }}",
"contentUrl": "{{ variant_image | image_url: width: 1200 | prepend: 'https:' }}",
"caption": {{ variant.title | prepend: ' - ' | prepend: product.title | json }}
},
"offers": {
"@type": "Offer",
"@id": "{{ shop.url }}{{ variant.url }}#offer",
"price": "{{ variant.price | divided_by: 100.00 }}",
"priceCurrency": "{{ cart.currency.iso_code }}",
"availability": "https://schema.org/{% if variant.available %}InStock{% else %}OutOfStock{% endif %}",
"url": "{{ shop.url }}{{ variant.url }}",
"itemCondition": "https://schema.org/NewCondition"
}
}{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
]
}
</script>

Validate variant schema and monitor AI visual search
Implementing this structured code resolves the data disconnect, but you need to verify how scrapers read the rendered HTML. Themes with aggressive JavaScript hydration or caching plugins can sometimes strip or rewrite JSON-LD script blocks before crawlers finish reading the DOM.
Open a product page in your browser with a specific variant parameter attached (such as ?variant=42031018102). View the page source directly—do not rely solely on the browser's developer console inspect element tool, which reflects post-load JavaScript modifications. Look for your custom ProductGroup block and verify the following elements:
- The parent
@idmatches the canonical URL appended with#product-group. - Each variant inside
hasVariantfeatures its own unique@idcarrying#variant-ID. - The
image.urlinside each childProductpoints to the designated photo for that color, not the store's primary thumbnail. - The nested
Offerblock includes the direct variant URL with its?variant=query parameter.
Submit the rendered URL to the Schema.org Validator and Google's Rich Results Test. The validation tool should display a recognized ProductGroup entry containing nested variants without warnings for duplicate identifiers.
Once your theme emits clean variant metadata, check how major conversational systems interpret your inventory. You can scan your AI visibility using Pendium to see how models like ChatGPT, Gemini, and Claude describe and cite your products. As multimodal AI search handles more visual shopping interactions, stores with explicit variant image schema will capture the customers that generic themes leave behind. Run your updated product pages through Pendium to benchmark your visual recommendation scores against competitors in your category.