Most Shopify themes ship some product structured data out of the box. The trouble is that "some" is often incomplete, occasionally invalid, and sometimes actively wrong in ways that cost you rich results in Google and clean parsing by AI assistants. This is a hands on guide to getting it right, written from the version we deploy on client stores.
Structured data is metadata you add to a page in a vocabulary search engines and assistants understand. The vocabulary is Schema.org, and the format everyone recommends now is JSON-LD, a small script block that sits in your HTML and describes the page as data. For a product page, the type you want is Product.
The minimum that actually qualifies
Google has documented requirements for product rich results. Miss a required field and you get nothing, no partial credit. At a minimum a Product needs a name, an image, and an offers block with a price and currency. Availability is technically optional but you should always include it, because it is the field assistants lean on most.
Here is a clean baseline for a single product:
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Trailhead Merino Base Layer",
"image": [
"https://cdn.shopify.com/.../trailhead-front.jpg",
"https://cdn.shopify.com/.../trailhead-back.jpg"
],
"description": "A 200gsm merino crew built for cold-weather running.",
"brand": { "@type": "Brand", "name": "Trailhead" },
"sku": "TH-BL-200-BLK-M",
"offers": {
"@type": "Offer",
"url": "https://store.com/products/trailhead-base-layer?variant=42",
"price": "118.00",
"priceCurrency": "CAD",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition"
}
}
A few things are worth saying about even this small block. The price must be a plain number as a string, with no currency symbol and no thousands separator. The availability value is a full Schema.org URL, not the word "in stock." The image should be a real, crawlable URL, ideally more than one. These sound obvious, and they are exactly the fields I most often find broken.
Variants are where Shopify themes go wrong
A Shopify product is usually a family of variants: sizes, colors, the same shirt in nine versions. Themes handle this inconsistently. Some emit one Offer for the first variant and call it done, which understates your range. Some emit a price range with no way to map a price to a buyable variant.
The cleaner pattern, when a product has multiple prices or stock states, is AggregateOffer, which expresses the range while still being honest:
"offers": {
"@type": "AggregateOffer",
"priceCurrency": "CAD",
"lowPrice": "108.00",
"highPrice": "118.00",
"offerCount": 9,
"availability": "https://schema.org/InStock"
}
If you want per variant precision, and you often do for apparel, you can emit an array of Offer objects, one per variant, each with its own sku, price, and availability, and each url carrying the ?variant= parameter so the link lands on that exact selection. That is more work and more markup, but it is the version that lets an assistant say "the medium in black is in stock at 118" instead of hedging.
Identifiers: the field that unlocks the most
If your products have GTINs, which covers UPC, EAN, and ISBN, include them. Google treats a valid GTIN as a strong trust and matching signal, and it is increasingly how product data gets reconciled across systems. On Shopify the barcode field is the natural home for this.
"gtin13": "0123456789012",
"mpn": "TH-BL-200",
"brand": { "@type": "Brand", "name": "Trailhead" }
If you genuinely do not have GTINs, which is common for made to order or private label goods, do not invent them. Use mpn and brand together instead, which Google accepts as an alternative identifier pair.
Reviews, but only real ones
aggregateRating and review can earn you star ratings in results, and they meaningfully lift click through. The rule is simple and strict: the rating must be visible on the page and it must be genuine. If you run Judge.me, Loox, Okendo, or another review app, pull the real average and count into the schema. Do not hardcode a glowing number, and do not mark up ratings that a shopper cannot see on the page. Google has handed out manual penalties for exactly that.
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.7",
"reviewCount": "212"
}
How to test it
Two tools, both free. Google's Rich Results Test shows you whether a live URL qualifies for product rich results and lists any errors or warnings. The Schema.org validator is stricter about the vocabulary itself and catches structural mistakes. Run a product page through both after any theme change, because theme updates are the most common way valid schema silently breaks.
A warning worth internalizing: warnings are not errors, but they are recommendations Google is making about fields that improve your eligibility. Treat "missing field: brand" as a task, not noise.
A note on doing this at scale
Hand writing JSON-LD into a Liquid template is fine for one store and one theme. It gets fragile across a large catalog, multiple currencies, and frequent theme work, which is the situation most growing brands are in. The data has to stay correct as products change, as variants sell out, and as you edit the theme that prints it.
That maintenance problem is why we built AgentReady, which generates valid Product schema for every product and keeps it accurate as the catalog moves, without touching your theme code. If you prefer to own the markup directly, the patterns above are the ones we would write by hand anyway. The important part is not who writes it. It is that the facts on the page are labeled, complete, and true, because that is what turns a product page into a result.

