Product Schema on Shopify: Beyond the Basics Most Stores Ship
Default Shopify product schema is incomplete. Full JSON-LD with aggregateRating, ShippingDetails, MerchantReturnPolicy, and copy-paste Liquid from 250+ builds.
Open the view-source of any Shopify product page running Dawn, Sense, Refresh, or any theme downstream of Online Store 2.0, and search for "@type":"Product". You will find a JSON-LD block. It will have a name, a description, an image, an SKU, a brand, and an offers object. It will not have an aggregateRating. It will not have shippingDetails. It will not have hasMerchantReturnPolicy. It will not have priceValidUntil. It will be, by the standard Google publishes in its Merchant listings documentation, an incomplete snippet that qualifies for neither the free shipping badge nor the free returns badge in search results, and that often loses the stars to the first review app that happens to inject its own competing Product block further down the page.
This is the default ceiling for a Shopify store's organic click-through rate, and it is the reason we touch the product schema on roughly nine out of every ten stores that come through WitsCode. Across 250 plus Shopify builds, the pattern is consistent. A thirty minute snippet swap unlocks the stars, the shipping badge, and the returns badge, and the click-through delta shows up in Google Search Console inside a fortnight. The rest of this article is exactly what we put in that snippet, why each field is there, and how to bridge Shopify's Liquid data into the Schema.org properties Google actually rewards in 2026.
What Shopify's Default Product Schema Actually Emits
The product | structured_data filter, and the inline JSON-LD that most OS 2.0 themes drop into sections/main-product.liquid, produce something close to this:
{
"@context": "https://schema.org/",
"@type": "Product",
"name": "Merino Crew Neck",
"description": "Lightweight merino wool crew neck sweater.",
"image": ["https://cdn.shopify.com/s/files/.../merino.jpg"],
"sku": "MC-001-M",
"brand": { "@type": "Brand", "name": "Northfield" },
"offers": {
"@type": "Offer",
"price": "129.00",
"priceCurrency": "GBP",
"availability": "https://schema.org/InStock",
"url": "https://northfield.com/products/merino-crew-neck"
}
}
That is enough to qualify for a basic Product snippet, which is the compact card with a price and an availability line. It is not enough for what Google now calls a merchant listing enhancement, which is the richer SERP treatment that stacks star rating, review count, currency formatted price, stock status, shipping fee, and return policy into the result block. The merchant listing enhancement requires, per Google's current eligibility rules, an aggregateRating or review property, a fully typed Offer with priceValidUntil where relevant, a valid OfferShippingDetails, and a valid MerchantReturnPolicy. Shopify emits none of those by default. The platform cannot. It does not know which review app you have installed, it has no API exposing the shipping zones into Liquid, and MerchantReturnPolicy did not exist as a spec when most theme defaults were last rewritten.
Before you add anything, delete what is already there. Two Product JSON-LD blocks on the same URL is the single most common reason a deploy that validates in the Rich Results Test still fails to produce rich results in the wild. Google will pick one block, often the shorter one. If your theme is Dawn, find the {% render 'structured-data-product' %} call or the inline {% schema %} adjacent block and comment it out. If you are on a vendored theme, search the product section template for application/ld+json and remove the product-typed block. Then put the complete snippet below in snippets/product-schema.liquid and render it once from main-product.liquid.
The Full Product Schema, Built for Shopify Liquid
Here is the snippet we deploy. It assumes review data lives in product metafields under the reviews namespace, which is the pattern Judge.me, Shopify Product Reviews legacy, and most Yotpo configurations end up with after a compatibility rewrite. It assumes shipping and return settings live in theme settings or shop metafields. Both assumptions are explained and unpicked below the block.
{%- liquid
assign variant = product.selected_or_first_available_variant
assign rating = product.metafields.reviews.rating.value
assign rating_count = product.metafields.reviews.rating_count
assign gtin = variant.barcode
assign price = variant.price | money_without_currency | replace: ',', ''
assign currency = shop.currency
assign availability = 'https://schema.org/OutOfStock'
if variant.available
assign availability = 'https://schema.org/InStock'
endif
-%}
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | truncatewords: 60 | json }},
"image": [
{%- for img in product.images limit: 4 -%}
{{ img | image_url: width: 1200 | prepend: 'https:' | json }}
{%- unless forloop.last -%},{%- endunless -%}
{%- endfor -%}
],
"sku": {{ variant.sku | json }},
{%- if gtin != blank -%}
"gtin": {{ gtin | json }},
{%- endif -%}
"mpn": {{ variant.sku | json }},
"brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
"url": {{ product.url | prepend: shop.url | json }}
{%- if rating and rating_count -%}
,"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "{{ rating | round: 2 }}",
"reviewCount": "{{ rating_count }}",
"bestRating": "5",
"worstRating": "1"
}
{%- endif -%}
,"offers": {
"@type": "Offer",
"price": "{{ price }}",
"priceCurrency": "{{ currency }}",
"availability": "{{ availability }}",
"itemCondition": "https://schema.org/NewCondition",
"url": {{ product.url | prepend: shop.url | json }},
{%- if variant.compare_at_price > variant.price -%}
"priceValidUntil": "{{ 'now' | date: '%s' | plus: 2592000 | date: '%Y-%m-%d' }}",
{%- endif -%}
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": "{{ settings.return_country | default: 'GB' }}",
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": {{ settings.return_days | default: 30 }},
"returnMethod": "https://schema.org/ReturnByMail",
"returnFees": "https://schema.org/FreeReturn"
},
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": "{{ settings.shipping_rate | default: 0 }}",
"currency": "{{ currency }}"
},
"shippingDestination": {
"@type": "DefinedRegion",
"addressCountry": "{{ settings.ship_country | default: 'GB' }}"
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": {
"@type": "QuantitativeValue",
"minValue": 0, "maxValue": 1, "unitCode": "DAY"
},
"transitTime": {
"@type": "QuantitativeValue",
"minValue": {{ settings.transit_min | default: 2 }},
"maxValue": {{ settings.transit_max | default: 5 }},
"unitCode": "DAY"
}
}
}
}
}
</script>
Rendered from a product page, this block gives Google every field it needs to unlock the full merchant listing treatment. The price is stripped of currency symbols because Schema.org requires a raw decimal. The image loop caps at four because Google deduplicates beyond that and the extra weight hurts nothing but the HTML size budget. The GTIN is written only if the variant barcode field is populated, because writing an empty gtin is worse than omitting it and will trigger a warning in the Rich Results Test. The priceValidUntil is written only when there is an active sale, because writing it on every product at a thirty day offset creates a permanent phantom sale signal that Google has started to discount.
Pulling Shipping Out of Shopify Policies
The weakest part of the spec, and the reason many developers never add ShippingDetails, is that Shopify does not expose shipping zones to Liquid. There is no shop.shipping_zones object. You cannot loop over rates at render time. You have three options, in order of how we rank them for the 250 plus stores we maintain.
The first option, which is the one embedded in the snippet above, is theme settings. In config/settings_schema.json add a section for structured data defaults and expose shipping_rate, ship_country, transit_min, transit_max, return_country, and return_days as number and text inputs. The merchant sets them once in the theme editor. They live in version control because settings_data.json is committed with the theme. When shipping policy changes, the schema changes in one place. This is the pragmatic default.
The second option is shop-level metafields, accessed as shop.metafields.shipping.rate and similar. This is preferable when shipping policy is managed by an operations team that does not have theme editor access, or when the same values need to be referenced from multiple templates and snippets without duplicating them across theme settings. Define the metafields once in the Shopify admin under Settings, Custom data, Shop, and render them the same way you render theme settings.
The third option, which we reserve for stores with genuinely complex zone rules, is to generate a per-country or per-collection shipping block inside a Liquid case statement that reads request.locale.iso_code or product.tags. You emit an array of OfferShippingDetails, one per country. Google accepts the array. The Rich Results Test will validate it. The downside is maintenance burden. A zone change now requires a theme edit, which is a higher-risk deploy. We only do this when the business genuinely has, for example, free shipping over eighty pounds in the UK, a five pound flat fee to the EU, and a fifteen dollar fee to the US, and the merchant is unwilling to fold those into one worst-case rate.
Whichever option you pick, the returns block is almost always simpler. Most stores have one policy, one window, one country. Hardcoding it into the snippet with a theme setting override is enough. The only genuine trap is returnPolicyCategory. If returns are not permitted, you must write MerchantReturnNotPermitted. If you have an unlimited window, use MerchantReturnUnlimitedWindow and omit merchantReturnDays. Writing MerchantReturnFiniteReturnWindow without a valid merchantReturnDays number fails validation silently.
Bridging Review Apps Through Metafields
The aggregateRating is the single highest-leverage addition in the entire snippet. Stars in a SERP lift click-through on commercial queries by a range we see cluster around twenty to thirty five percent in GSC data post-deploy. Every major Shopify review app now writes review summaries to product metafields so that themes can render them without a script tag. The namespaces differ.
Judge.me writes product.metafields.reviews.rating (a rating type, access via .value) and product.metafields.reviews.rating_count. Yotpo, after enabling the native metafield sync in its app dashboard, writes to product.metafields.yotpo.reviews_average and product.metafields.yotpo.reviews_count. Loox writes to product.metafields.loox.avg_rating and product.metafields.loox.num_reviews. Stamped and Okendo follow similar patterns under their own namespaces. If you are running a mixture, a common state in stores that migrated apps, you need a Liquid guard at the top of the snippet that checks each namespace in priority order and assigns the first non-empty pair to rating and rating_count. Do not average across apps. Pick one.
The subtle failure mode here is the review app that injects its own Product JSON-LD alongside yours. Judge.me, Loox, and Yotpo all offer this as a legacy setting, usually labelled something like "add rich snippets" or "show stars in search results". Turn it off in every app that exposes the toggle. You are now the single source of the Product schema on that page. Two Product blocks is worse than none, because Google arbitrarily picks one and the review app's version is almost always less complete than yours.
The Rich Results Validation Loop
Deploying the snippet is not the same as earning the rich result. Google validates eligibility continuously and can demote a URL if it detects drift between the schema and the rendered page. The validation loop we run on every launch is four steps.
Paste the live product URL into the Rich Results Test at search.google.com/test/rich-results. Confirm the Product snippet and Merchant listings enhancements both appear with zero errors. Warnings about optional recommended fields (review array, additional GTIN types, audience) are safe to ignore for a first deploy. Errors about missing required fields or invalid enumeration values are blockers. The second pass is the Schema Markup Validator at validator.schema.org, which is stricter on Schema.org spec compliance than Google's validator and catches typed value errors that Google tolerates.
Then open Google Search Console, use URL Inspection on the product URL, and hit Request Indexing. Repeat for three or four high-traffic product URLs so you have a sample. Wait fourteen days. Open the Enhancements section of GSC, expand Product snippets and Merchant listings. Valid item counts should climb. If they do not, the most common causes in order are a lingering second Product block from the old theme snippet, a review app writing its own JSON-LD, a shippingDetails block with a malformed MonetaryAmount, and a priceValidUntil in the past.
The fourth step, which most teams skip and which is the reason their schema never survives a theme update, is to commit the snippet, the snippets/product-schema.liquid path, and the settings_schema.json additions into version control alongside a README entry that says this file owns the Product JSON-LD and any other source must be removed. Without that note, the next theme update quietly reinstates the default block and you are back to two competing sources.
What Rich Results Do for Commercial Queries
The reason we invest the engineering time on every client rather than treating schema as a nice-to-have is the click-through delta. In the GSC data we track across the WitsCode portfolio, product pages with a complete merchant listing enhancement (stars, price, free shipping badge, free returns badge) outperform pages with only a basic Product snippet by a median of twenty six percent on commercial transactional queries, and the gap is wider on mobile where the SERP real estate premium for rich results is larger. A page ranking at position three with the full enhancement frequently outperforms a plain position one result in raw clicks.
The implication is that rich results are now a ranking multiplier, not a ranking factor. Google is not ranking your product page higher because your schema is complete. Google is giving users more reasons to click your result when it appears, which feeds back into the behavioural signals that do influence ranking over time. That is the loop we are buying into with thirty minutes of snippet work. The Shopify default ships you to the starting line. The schema above is what runs the race.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
Shopify Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss ecom for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.

