Product schema is the structured data that tells Google your product's name, price, availability, and rating. Get it right and that's the difference between a plain blue link in search results and a listing with a price, a star rating, and stock status attached.
Schema also helps you show up in AI citations. In our own AI Search Readiness Report, a scan of 1,000 Shopify stores, product pages scored higher than any other page type. Schema is a big reason why.
This is the how-to for product page schema implementation, down to the Liquid that goes in your theme.
What you need before you start
-
Access to your theme's code editor (Online Store > Themes > Edit Code)
-
Your store's return window and shipping policy details, not placeholder numbers
-
The metafield namespace your reviews app uses, if you run one
First, check if your theme already has product schema
Most Online Store 2.0 themes, Dawn included, already output basic Product schema through Shopify's own structured data Liquid filter. Adding a second, hand-written block on top of it is one of the most common ways stores end up with duplicate schema, and Google flags that as an error. It doesn't just merge the two.
-
Open any product page on your live store.
-
Right-click the page and choose View Page Source (or press Ctrl+U on Windows, Cmd+Option+U on Mac).
-
Search the page source for application/ld+json.
-
Check the @type value inside the script tag.
Page Source on a live product page with the existing application/ld+json block highlighted
You may see "@type": "Product", or "@type": "ProductGroup" on a product with variants. Don't add a second block.
Keep reading anyway, since the built-in filter doesn't output review, shipping, or return details, and those are what full merchant listing eligibility needs.
|
Covered by the default filter |
Not covered, needs manual work |
|
Name, image, price |
aggregateRating (star ratings) |
|
Availability |
shippingDetails |
|
Basic brand |
hasMerchantReturnPolicy |
|
Variant grouping (as ProductGroup) |
GTIN, MPN, and other identifiers |
If there's nothing there, or what's there is thin, you're building from scratch.
Should you use an app or write the code yourself?
|
Schema app |
Custom Liquid |
|
|
Best for |
No theme code access |
Full control, no ongoing dependency |
|
Updates with your data |
Depends on the app staying installed and configured |
Automatically, every page load |
|
Duplicate-schema risk |
Common, since many apps output their own Product block on top of the theme's |
None, if you check for existing schema first |
If you can edit theme code, custom Liquid is the better long-term option as doesn't depend on a third-party app and it doesn't disappear if you switch reviews or SEO apps later.

There are multiple apps to choose from that can do the job for you.
Top Shopify schema apps
If you are considering using apps the table below will make the choice easier.
| App | Developer | Price | Rating (reviews) | Notes |
|---|---|---|---|---|
| Ilana's JSON‑LD for SEO | Ilana Davis LLC | $399/year (7‑day trial) | 4.9 (~420) | Schema-only, deepest field coverage, 20+ review-app integrations. Highest entry price. |
| Schema Plus for SEO | Uppercase Apps | $14.99/month (7‑day trial) | 4.9 (~90) | Single tier, no upsell. Built by ex-Google engineers. Product, FAQ, Breadcrumb, review integrations. |
| Sherpas: Smart SEO | Sherpas Design | Free plan; Pro $14.99/month | 4.9 (large base) | JSON‑LD unlocks on Pro. Bundles metadata, image, and sitemap tools, so it's broader than schema alone. |
| SearchPie SEO | EGO / SearchPie | Free plan; premium ~$39/month | 4.9 (2,280+) | Full SEO suite with schema included, plus backlink monitoring. Schema is one feature, not the focus. |
Now let's head into the manual schema implementation.
1. Find the right theme file to edit
Go to Online Store > Themes > Edit Code.
|
What you're editing |
File location |
|
Product schema (Online Store 2.0 themes) |
sections/main-product.liquid |
|
Product schema (older / vintage themes) |
templates/product.liquid, or a separate snippets/product-schema.liquid included from it |
|
Site-wide schema (Organization, WebSite) |
layout/theme.liquid, inside the <head> tag |
Search whichever file applies for structured_data or application/ld+json to find any existing block.
Keep product schema and site-wide schema in separate files. Product schema only applies to product pages, so it doesn't belong in the layout file, and mixing the two makes it harder to isolate a problem later.
If you need to set up Organization or WebSite schema in theme.liquid, that's covered in our complete structured data guide.
2. Add the base product schema code
Below is a working starting point. It uses Liquid to pull real values instead of hardcoding anything, and it covers a single-variant product.
liquid
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | truncatewords: 40 | json }},
"sku": {{ product.selected_or_first_available_variant.sku | json }},
"brand": {
"@type": "Brand",
"name": {{ product.vendor | json }}
},
"image": [
{% for image in product.images limit: 5 %}
{{ image | image_url: width: 1200 | prepend: "https:" | json }}{% unless forloop.last %},{% endunless %}
{% endfor %}
],
"offers": {
"@type": "Offer",
"url": {{ shop.url | append: product.url | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"price": {{ product.selected_or_first_available_variant.price | divided_by: 100.0 | json }},
"itemCondition": "https://schema.org/NewCondition",
"availability": "https://schema.org/{% if product.selected_or_first_available_variant.available %}InStock{% else %}OutOfStock{% endif %}"
}
}
</script>
Two details in the code that matter:
-
The json filter handles quote escaping for you. This fixes the smart-quote and broken-character bugs that show up when someone hand-types JSON around Liquid output. Don't build these strings with typed quote marks yourself.
-
Price is stored in cents. Shopify stores every price as a whole number in the smallest currency unit, cents for USD. product.selected_or_first_available_variant.price returns that raw number, which is why it gets divided by 100 here. For a currency with no subunit, like JPY, skip the division and check the raw value first.
3. Choose how to represent variants
The block above only shows one price. If a product has variants, you have three options, and the right one depends on what you're building toward.
|
Single Offer |
AggregateOffer |
ProductGroup + hasVariant |
|
|
Shows |
One specific price, |
A low/high price range across all variants |
Every variant individually, each with its own name, SKU, and price |
|
Best for |
A simple product, |
A basic Product rich result showing a range |
Multi-variant products where you want each variant shown and eligible |
|
Merchant listing eligibility |
Yes |
No |
Yes, and it's Google's recommended structure for this exact case |
|
Setup |
product.selected_or |
Loop over product.variants, sort, take first and last |
Loop over product.variants, output each as its own Product |
Google's own documentation is explicit that ProductGroup is the recommended approach for products with real variants, and that it makes them eligible for merchant listings with variant information displayed. If a product has genuine size, color, or material variants, this is the more complete option, not just an alternative to the two above.
liquid
{% assign product_options = product.options_with_values %}
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "ProductGroup",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | truncatewords: 40 | json }},
"brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
"productGroupID": {{ product.id | json }},
"variesBy": [
{% for option in product_options %}
{{ option.name | downcase | json }}{% unless forloop.last %},{% endunless %}
{% endfor %}
],
"hasVariant": [
{% for variant in product.variants %}
{
"@type": "Product",
"name": {{ variant.title | json }},
"sku": {{ variant.sku | json }},
"offers": {
"@type": "Offer",
"url": {{ shop.url | append: product.url | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"price": {{ variant.price | divided_by: 100.0 | json }},
"availability": "https://schema.org/{% if variant.available %}InStock{% else %}OutOfStock{% endif %}"
}
}{% unless forloop.last %},{% endunless %}
{% endfor %}
]
}
</script>
variesBy lists which options actually distinguish the variants, color, size, material, pulled straight from product.options_with_values so it stays correct if you add or remove an option later.
This structure has one downside. Repeating shippingDetails and hasMerchantReturnPolicy inside every variant's Offer gets tedious fast on a product with a dozen variants.
If your return and shipping policy is the same across your catalog, set it once at the Organization level (in theme.liquid, or in Google Merchant Center) instead of nesting it into every variant.
If you don't have real variants to represent, AggregateOffer still works for a simpler price-range display:
liquid
{% assign variant_prices = product.variants | map: 'price' | sort %}
"offers": {
"@type": "AggregateOffer",
"priceCurrency": {{ cart.currency.iso_code | json }},
"lowPrice": {{ variant_prices.first | divided_by: 100.0 | json }},
"highPrice": {{ variant_prices.last | divided_by: 100.0 | json }},
"offerCount": {{ product.variants.size }},
"availability": "https://schema.org/{% if product.available %}InStock{% else %}OutOfStock{% endif %}"
}
If merchant listing eligibility matters and your product doesn't have meaningful variants, use the single Offer from the base block instead.
4. Add review data from your ratings app

How reviews structured data look like from Hrefs Toolbar
If you're running a reviews app, Judge.me, Loox, Yotpo, or similar, it's already writing rating data to product metafields you can pull into schema. The metafield namespace depends on which app you use, so check yours first.
-
Go to Settings > Custom Data > Products.
-
Look for a metafield related to reviews, rating, or your app's name.
-
Note the namespace and key, since that's what goes into the Liquid below.
liquid
{% assign variant_prices = product.variants | map: 'price' | sort %}
"offers": {
"@type": "AggregateOffer",
"priceCurrency": {{ cart.currency.iso_code | json }},
"lowPrice": {{ variant_prices.first | divided_by: 100.0 | json }},
"highPrice": {{ variant_prices.last | divided_by: 100.0 | json }},
"offerCount": {{ product.variants.size }},
"availability": "https://schema.org/{% if product.available %}InStock{% else %}OutOfStock{% endif %}"
}

How the liquid code for reviews looks in the Shopify admin
Keep this wrapped in the conditional shown above. A product with zero reviews should never output an empty or zero-valueaggregateRating block. Google's structured data guidelines treat that as misleading markup, and a violation can trigger a manual action that makes the page ineligible for rich results, not just a warning. If the metafield is blank, skip the block entirely.
A view insideJudge.me review app dashboard
5. Your theme's default schema won't get you into merchant listings
Basic theme schema almost always stops at name, price, and availability. This is enough for a plain Product rich result, but it's not enough for the wider merchant listing features: the Shopping knowledge panel, Popular Products, and shopping results in Google Images.
Those require shippingDetails and hasMerchantReturnPolicy inside the offer. No Shopify theme adds either on its own
Google also requires the offer price to be greater than zero for merchant listing eligibility. A $0 placeholder price, common on unfinished draft products, will quietly disqualify the page.
If your return policy and shipping rates are the same across your whole catalog, the simpler move is setting them once in Google Merchant Center instead of repeating them in every product's schema.
If you're not using Merchant Center, or you'd rather the data travel with the schema itself, add it directly. MerchantReturnPolicy needs the following:
|
Property |
Status |
Notes |
|
applicableCountry |
Required |
Two-letter ISO 3166-1 country code |
|
returnPolicyCategory |
Required |
One of Google's return category values |
|
returnPolicyCountry |
Recommended |
Google added this to its own examples alongside applicableCountry |
|
merchantReturnDays |
Required, if the category is a finite window |
Number of days from delivery |
|
returnMethod |
Recommended |
How the item gets returned |
|
returnFees |
Recommended |
Must be FreeReturn if you use this property at all |
liquid
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": "US",
"returnPolicyCountry": "US",
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": 30,
"returnMethod": "https://schema.org/ReturnByMail",
"returnFees": "https://schema.org/FreeReturn"
},
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": "0",
"currency": {{ cart.currency.iso_code | json }}
},
"shippingDestination": {
"@type": "DefinedRegion",
"addressCountry": "US"
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": {
"@type": "QuantitativeValue",
"minValue": 0,
"maxValue": 1,
"unitCode": "DAY"
},
"transitTime": {
"@type": "QuantitativeValue",
"minValue": 2,
"maxValue": 5,
"unitCode": "DAY"
}
}
}
If returns aren't accepted at all, use MerchantReturnNotPermitted instead of the finite window category, and drop merchantReturnDays.
The numbers above are placeholders. Pull your actual return window and handling time from your real policy pages, not from this example. If your policy changes by season, pull these values from a theme setting or metafield you control from the admin, instead of hardcoding them in the file.
6. Product schema and AI citations
Rich results are the Google payoff. AI is the other half, and it runs on the same block. ChatGPT Shopping and Perplexity read your JSON-LD before the visible page, so clean Product markup means they quote your price, rating, and stock accurately instead of guessing.
But schema alone won't earn the citation. When we studied 1,000 Shopify stores, 88% emitted product structured data, yet on 59% the AI-readable description was thin or missing, and across every source AI tools cited, only 2.8% were a brand's own page.
Fill the description field with real, product-specific copy, not keywords, since that's the text an engine quotes with the most confidence.

Use our agentic commerce readiness check tool
GTIN, MPN, and SKU let an engine tie your item to the same product sold elsewhere and decide whose page to cite. Complete, consistent identifiers plus original copy are what mark your page as the origin instead of a reseller. 
You can see where your own store stands with our agentic commerce readiness check.
7. Adding the complete code
Everything above combined into one block, for a store with a single sellable offer per page, an active reviews app, and a standard return policy. If you've been following along, nothing here is new, this is just all of it assembled in one place to copy from:
liquid
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | truncatewords: 40 | json }},
"sku": {{ product.selected_or_first_available_variant.sku | json }},
"brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
"image": [
{% for image in product.images limit: 5 %}
{{ image | image_url: width: 1200 | prepend: "https:" | json }}{% unless forloop.last %},{% endunless %}
{% endfor %}
],
{% if product.metafields.reviews.rating.value != blank %}
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": {{ product.metafields.reviews.rating.value.rating | json }},
"reviewCount": {{ product.metafields.reviews.rating_count.value | json }}
},
{% endif %}
"offers": {
"@type": "Offer",
"url": {{ shop.url | append: product.url | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"price": {{ product.selected_or_first_available_variant.price | divided_by: 100.0 | json }},
"itemCondition": "https://schema.org/NewCondition",
"availability": "https://schema.org/{% if product.selected_or_first_available_variant.available %}InStock{% else %}OutOfStock{% endif %}",
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": "US",
"returnPolicyCountry": "US",
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": 30,
"returnMethod": "https://schema.org/ReturnByMail",
"returnFees": "https://schema.org/FreeReturn"
},
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": { "@type": "MonetaryAmount", "value": "0", "currency": {{ cart.currency.iso_code | json }} },
"shippingDestination": { "@type": "DefinedRegion", "addressCountry": "US" },
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": { "@type": "QuantitativeValue", "minValue": 0, "maxValue": 1, "unitCode": "DAY" },
"transitTime": { "@type": "QuantitativeValue", "minValue": 2, "maxValue": 5, "unitCode": "DAY" }
}
}
}
}
</script>
Paste this inside sections/main-product.liquid, save, and reload a live product page. For a product with real variants, use the ProductGroup version from the earlier section instead, since it's the more complete and Google-recommended structure; use AggregateOffer only if you just want a simple price range and don't need merchant listing eligibility.
8. Validate your schema
|
Tool |
Scope |
Use it for |
|
Google Rich Results Test |
One URL at a time |
Quick check right after an edit |
|
Search Console Merchant Listings report |
Whole catalog |
Ongoing monitoring, catching a field missing store-wide |
Run the live product URL through the Rich Results Test first. It tells you whether the Product type was detected, lists any errors that block eligibility, and separately lists warnings for optional fields you've skipped. A clean pass shows no errors.

Rich Results Test result for a product URL

Often time you will find invalid items and duplicated pages
Search Console's Merchant Listings report, under Enhancements, is where you'll catch problems at scale instead of one URL at a time.

Warnings aren't errors. A product with a warning for missing shippingDetails still qualifies for a basic Product rich result. It just won't clear the bar for the wider merchant listing features.
Common errors to avoid
Duplicate schema deserves ongoing attention, not just a launch-day check: any SEO or reviews app installed later can inject its own Product schema on top of what's already there. Check page source the same way you did at the start of this tutorial whenever you install something new.
|
Error |
Why it happens |
Fix |
|
Duplicate schema |
Theme already outputs Product schema, and a second block gets added on top, by hand or by a newly installed app |
Check page source first, per the earlier section. If a new app caused it, disable its schema output in the app's settings or remove its script tag from the theme. |
|
Product schema on collection pages |
Someone tries to mark up a page listing many products as if it were one product |
Use ItemList or CollectionPage for category and collection pages. Reserve Product and ProductGroup for individual product pages. |
|
Price formatted wrong |
Currency symbols, commas, or the raw cents value left undivided |
Price should read as a plain number, no symbols or separators |
|
Typed quotes instead of the json filter |
JSON copied from a doc or email straight into the code editor |
Run every string value through | json instead of typing quote marks |
|
Empty required fields |
A variant has no price set yet, on an out-of-stock or unfinished product |
Add a conditional check before the script tag renders at all |
Schema is not a one time job
Basic theme schema gets you a plain rich result. The full merchant listing, and the AI citations that come with clean structured data, need the review, shipping, and return fields the theme never adds. Build them once in the theme file and they stay correct as your catalog grows.
The only real work after that is checking page source whenever you install a new app, since that's where duplicate schema creeps back in. If you'd rather have it built, validated, and monitored for you, that's what Shero's structured data implementation does.