Skip to content

Structured data for product groups

Add schema.org/ProductGroup JSON-LD to your product pages so Google treats your linked products as variants of each other. The markup unlocks rich results, correct Google Shopping behaviour, and proper variant clustering in search.

The data the snippet needs is already in your product metafields, so no external service is involved. The Liquid below reads it and emits valid ProductGroup markup.

The snippet reads product.metafields.pl_swatches.groups, which only exists when public metafields are on. See Public metafields for how to enable the toggle and what gets written.

Save this as snippets/platmart-structured-data.liquid in your theme:

{%- comment -%}
Platmart Color Swatches - ProductGroup JSON-LD structured data
Builds schema.org/ProductGroup markup from swatch group metafields.
Usage: {% render 'platmart-structured-data', product: product %}
{%- endcomment -%}
{%- assign groups = product.metafields.pl_swatches.groups.value -%}
{%- if groups == blank -%}{%- break -%}{%- endif -%}
{%- for group in groups -%}
{%- comment -%} Skip groups that only show on collection pages {%- endcomment -%}
{%- if group.display_for == "collections" -%}{%- continue -%}{%- endif -%}
{%- comment -%} We need at least 2 swatches for a ProductGroup to make sense {%- endcomment -%}
{%- if group.swatches.size < 2 -%}{%- continue -%}{%- endif -%}
{%- comment -%} Find the current product in the swatches list {%- endcomment -%}
{%- liquid
assign current_swatch = nil
for swatch in group.swatches
if swatch.handle == product.handle
assign current_swatch = swatch
break
endif
endfor
-%}
{%- if current_swatch == nil -%}{%- continue -%}{%- endif -%}
{%- comment -%}
Try to map the option name to a schema.org property.
color, size, material, pattern are natively supported.
Anything else falls back to additionalProperty.
{%- endcomment -%}
{%- liquid
assign option_lower = group.option_name | downcase
assign schema_properties = "color,size,material,pattern" | split: ","
assign variant_property = blank
for prop in schema_properties
if option_lower == prop
assign variant_property = prop
break
endif
endfor
-%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"name": {{ product.title | json }},
"productGroupID": {{ group.group_id | json }},
{%- if variant_property != blank -%}
"variesBy": [{{ variant_property | prepend: "https://schema.org/" | json }}],
{%- endif -%}
"hasVariant": [
{
"@type": "Product",
"name": {{ product.title | json }},
"url": {{ product.url | prepend: request.origin | json }},
{%- if variant_property != blank -%}
{{ variant_property | json }}: {{ current_swatch.name | json }},
{%- else -%}
"additionalProperty": {
"@type": "PropertyValue",
"propertyID": {{ group.option_name | json }},
"value": {{ current_swatch.name | json }}
},
{%- endif -%}
{%- if product.featured_image -%}
"image": {{ product.featured_image | image_url: width: 1200 | json }},
{%- endif -%}
"offers": [
{%- for variant in product.variants -%}
{%- if forloop.first == false -%},{%- endif -%}
{
"@type": "Offer",
"name": {{ variant.title | json }},
"sku": {{ variant.sku | json }},
"price": {{ variant.price | divided_by: 100.0 }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"availability": {%- if variant.available -%}"https://schema.org/InStock"{%- else -%}"https://schema.org/OutOfStock"{%- endif -%},
"url": {{ product.url | append: '?variant=' | append: variant.id | prepend: request.origin | json }}
}
{%- endfor -%}
]
}
{%- for swatch in group.swatches -%}
{%- if swatch.handle == product.handle -%}{%- continue -%}{%- endif -%}
{%- assign variant_url = product.url | replace: product.handle, swatch.handle | prepend: request.origin -%}
,{
"@type": "Product",
"url": {{ variant_url | json }}
}
{%- endfor -%}
]
}
</script>
{%- endfor -%}

The snippet handles the edge cases: it skips groups set to display on collections only, requires at least two swatches per group to emit anything, and maps common option names (color, size, material, pattern) to native schema.org properties, falling back to additionalProperty for anything else.

In your product template (usually sections/main-product.liquid or templates/product.liquid), add:

{% render 'platmart-structured-data', product: product %}

The script tag renders inline. Google picks it up the next time it crawls the page.

You only want one ProductGroup block per page. Some themes already emit one (recent OS 2.0 themes often do). Search your theme for schema.org/ProductGroup or {{ product | structured_data }} and either delete the duplicate or comment it out:

{% comment %} Replaced by Platmart structured data
{{ product | structured_data }}
{% endcomment %}

Multiple ProductGroup blocks on the same page won’t break rendering, but Google can’t tell which one is canonical, which hurts indexing.

Run a product page through one of these:

Both accept a URL or pasted HTML. The Rich Results Test is the more useful one day-to-day, since it tells you what Google will actually do with what you emit.

For a “Backpack - Red” product in a Color group with Red, Blue, and White, with sizes S/M/L:

{
"@context": "https://schema.org",
"@type": "ProductGroup",
"name": "Backpack - Red",
"productGroupID": 59,
"variesBy": ["https://schema.org/color"],
"hasVariant": [
{
"@type": "Product",
"name": "Backpack - Red",
"url": "https://example.myshopify.com/products/backpack-red",
"color": "Red",
"image": "https://cdn.shopify.com/.../backpack-red.jpg",
"offers": [
{
"@type": "Offer",
"name": "S",
"sku": "BP-RED-S",
"price": 49.99,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"url": "https://example.myshopify.com/products/backpack-red?variant=1001"
}
]
},
{ "@type": "Product", "url": "https://example.myshopify.com/products/backpack-blue" },
{ "@type": "Product", "url": "https://example.myshopify.com/products/backpack-white" }
]
}

The current product gets the full breakdown including offers and image. Linked variants appear as Product entries with just a URL, which gives Google enough to crawl them as siblings.