Shopify Product Schema Implementation Tutorial

Ildi Veliu

Written by Ildi Veliu

Shopify Product Schema Implementation Tutorial

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.

  1. Open any product page on your live store.

  2. Right-click the page and choose View Page Source (or press Ctrl+U on Windows, Cmd+Option+U on Mac).

  3. Search the page source for application/ld+json.

  4. 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,
the currently selected or
first available variant

A low/high price range across all variants

Every variant individually, each with its own name, SKU, and price

Best for

A simple product,
or picking one representative offer

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
_first_available_variant.price

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

Ahrefs SEO Toolbar showing ProductGroup structured data with aggregateRating highlighted for Nike Air Max 270 product
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.

  1. Go to Settings > Custom Data > Products.

  2. Look for a metafield related to reviews, rating, or your app's name.

  3. 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 %}"
}

Schema validator showing JSON-LD ItemList markup for Shero Commerce client testimonials, zero errors and warnings

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.

Judge.me Reviews dashboard listing hitch products with star ratings and recent five-star customer reviewsA 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.

Anatomy of an LLM readable product page, ten callouts with data from the Shero study of 1,000 Shopify stores

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.

FAQs on Shopify Product Schema


Does this apply if I sell services or digital downloads, not physical products?

Product schema covers physical goods and downloadable products. For services, use Service schema instead, or LocalBusiness if the service is tied to a physical location.

Does I need schema Google's newer shopping features, like Universal Cart?

Not directly. Universal Cart and UCP-powered checkout, announced in May 2026 and already live with some Shopify merchants, run through Merchant Center and a separate integration, not through Product schema. Schema still earns the rich result in the first place, but it's a different eligibility path.

How long before rich results appear after I add schema?

It isn't instant. Google has to recrawl the page, validate the markup, and decide to trust it, which usually takes days and can run longer on lower-traffic stores. The Rich Results Test confirms your markup is eligible right away. Actual display in search is Google's call and on Google's timeline. If a clean page still shows nothing after a few weeks, request indexing on that URL in Search Console before assuming the schema is broken.

What availability value do I use for preorders or backorders?

InStock and OutOfStock aren't the only options. Use https://schema.org/PreOrder for items available to buy before release, and https://schema.org/BackOrder for items you're still selling with a delayed ship date. Mapping every unavailable variant to OutOfStock hides sellable inventory from Shopping results. Drive the value from the variant's real state rather than a single catch-all condition.

Which price shows if I sell in multiple currencies?

The base block outputs one currency, pulled from cart.currency.iso_code. On a Shopify Markets store, that resolves to the currency of the request, so the schema follows the visible price rather than fighting it. The thing to check is that the priceCurrency and price always come from the same source. A price in USD paired with a EUR currency code is the kind of mismatch that gets a page flagged. If you serve fixed per-market prices, validate one URL per market, not just your home currency.

Ildi Veliu

Ildi is a Technical SEO Specialist at Shero Commerce on a mission to help websites unlock their full organic potential. With a background in Informatics-Economics, he bridges the gap between technical web development and search engine algorithms, specializing in technical audits and search performance strategy. He is always looking for new ways to outsmart the algorithms and drive high-intent organic traffic.