Shopify product
structured data, end to end.
Where the Product, Offer and ProductGroup JSON-LD on a Shopify product page comes from, what Google’s merchant-listing and variant documentation asks of it, how theme and app markup collide, and exactly how Rank Sniper’s scan reads it.
- AIO · Variant-level offers
- Weight 12 of 108 in the AIO pillar. Passes when a product-type node in the raw HTML carries an
offerskey. With no product markup found it scores zero, not unscored. Fix class by design: Automatic. - AIO · Price with priceCurrency
- Weight 12 of 108 in the AIO pillar. Passes when the JSON-LD on a sampled page contains both
priceandpriceCurrency. Fix class by design: Automatic. - AIO · Availability in schema
- Weight 12 of 108 in the AIO pillar. Passes when a product-type node carries an
availabilitykey. Fix class by design: Automatic. - AIO · Schema conflict detection
- Weight 8 of 108 in the AIO pillar. Fails when one page’s JSON-LD states more than one distinct price, or more than one availability. Fix class by design: Guided.
- Composite · schema sub-score
- Product JSON-LD 40, offers 20, availability 20, shipping details or return policy 20 — out of 100, blended at 20% of the composite.
- Fixes to this markup
- Planned Automatic fixes — app-block structured-data markup.
One product record,
restated for machines.
Product structured data repeats what the page already says — name, image, price, currency, availability, identifiers — in a vocabulary a parser can read without interpreting the layout.
On a Shopify product page the markup is a JSON-LD block — a script element of type application/ld+json — holding a Product node in schema.org vocabulary, usually with an Offer inside it. None of the facts in it are new. The title, the variant prices, the SKUs, the barcodes and the stock state already live in your Shopify admin; the markup is a translation of them into a form that does not depend on how your theme lays the page out.
That framing explains most of what goes wrong. Structured data is rarely wrong about the product; it is wrong about the translation. A template that writes InStock as a literal string, an Offer that only ever describes the first variant, a price with no currency, two apps each writing their own Product node — every one of those is a faithful copy of the wrong thing.
What it does not do
Google is explicit that none of this is a ticket into its generative AI features:
“Structured data isn't required for generative AI search, and there's no special schema.org markup you need to add.”Google Search Central — Optimizing your website for generative AI features on Google Search
The same guidance says structured data stays useful for rich-result eligibility, and Google’s AI features page makes the point from the other side: there is “no special schema.org structured data that you need to add” to appear in AI Overviews or AI Mode. So the case for getting Product markup right is not a promised placement. It is that JSON-LD is the one place on the page where price, currency and availability are stated as fields rather than as styled text. That is what Rank Sniper measures — whether a machine can read the record without guessing, its machine readability — not whether any engine rewards it.
Theme, app,
or both at once.
A product page can carry JSON-LD from the theme, from an app, or from both. Each arrives in the page differently, and the difference decides what a reader that runs no scripts receives.
Written by the theme
If your theme writes product JSON-LD, it does so in its Liquid templates, so the block is part of the HTML the storefront returns for the product URL. That is the most robust place for it: every reader that fetches the page gets the same bytes, whether or not it runs scripts. It is also where markup goes stale, because a template is a snapshot of what its author thought the specification asked for on the day it shipped — the story our journal’s theme audit tells in detail.
Added by an app
An app can add product markup in two ways that look identical in a browser. It can render it into the page’s HTML — for example through an app block the theme outputs with the rest of the template — or it can run a script that inserts a JSON-LD block after the page has loaded. The first is in the response; the second exists only in the rendered page. Your browser’s inspector shows the rendered page, after every script has run. View-source shows the response. Only the response reaches a reader that does not execute JavaScript, and Rank Sniper’s scanner is such a reader: it fetches raw HTML and runs no scripts.
Both
The common failure on a mature store is not missing markup but doubled markup: the theme’s Product node and an app’s Product node on the same page. They rarely agree. One formats the price as 25.0, the other as 25.00; one hard-codes availability, the other reads inventory; one describes the first variant, the other the selected one. A parser that finds two records for one product has no rule for choosing between them. This is the case the scan’s schema-conflict check exists for.
To see which sources your page carries, list every @type in the raw response:
curl -s https://yourstore.example/products/your-handle \
| grep -o '"@type": *"[A-Za-z]*"' \
| sort | uniq -cTwo Product lines and no ProductGroup on a single product usually means two sources. A ProductGroup with several nested Product lines is the variant pattern described below, and is expected. No output at all, while an app reports structured data as installed, means the markup is injected by script and is absent from the response. Declared versus delivered covers that gap on its own.
A validator’s green result does not settle provenance. It tells you the markup is valid, not where it came from. Google’s Rich Results Test runs as Google-InspectionTool, which Google lists among its crawlers with “no effect on Google Search” — useful for syntax, silent about which source wrote the block. Check the raw response as well.
What Google’s documentation
actually asks for.
Two Google documents govern product markup on a store page: the merchant-listing guide for Product and Offer, and the product-variants guide for ProductGroup. Required and recommended are different words there, and worth keeping apart.
- Product — required
name,imageandoffers, per the merchant-listing guide.- Offer — required
price, which must be greater than zero, andpriceCurrencyas an ISO 4217 code such asCADorEUR.- Offer — recommended
availability,itemCondition,url,shippingDetailsandhasMerchantReturnPolicy.- ProductGroup — required
name, per the product-variants guide (updated 2026-09-08).- ProductGroup — recommended
aggregateRating,brand,description,hasVariantandreview— withvariesBynaming what the variants differ by, such as size or colour, andproductGroupIDas the parent identifier.
Note what is not in the required set: availability is recommended, not required, for a merchant listing. Rank Sniper scores it anyway, for a reason that has nothing to do with Google’s eligibility rules — a record that states a price but not whether the item can be bought is one an agent cannot act on — and says so, rather than presenting it as a Google requirement. Ratings are the opposite case: recommended on a ProductGroup, and worth marking up only where real reviews render on the page. A rating the page does not show is a claim nobody can check.
| Shopify source | Google statusMerchant listing · variants | Rank Sniper reads it as | |
|---|---|---|---|
| name | Product title; the variant title on a variant node | Required | Part of the composite’s Product JSON-LD component (40 of 100) |
| image | Product media; the variant’s own image where it has one | Required | Not read from markup — the Imagery present check reads the catalogue (8 of 108 AIO) |
| offers | Variant price and inventory | Required | Variant-level offers (12 of 108 AIO) and the composite offers component (20) |
| price · priceCurrency | Variant price; the currency the page sells in | Required in Offer | Price with priceCurrency (12 of 108 AIO) |
| availability | Variant inventory state | Recommended | Availability in schema (12 of 108 AIO) and the composite availability component (20) |
| shipping · returns | Your shipping settings; your refund policy | Recommended | Composite shipping component (20) — either field counts |
| hasVariant · variesBy | Variants; option names | Recommended on ProductGroup | ProductGroup is recognised as product markup; offers nested in it are read |
| gtin · mpn | Variant barcode, where it is a GTIN | See the product-data guide | Detected, not scored in rubric v1.0 |
One group,
one offer per variant.
A Shopify product with sizes or colours is a ProductGroup in schema.org terms: shared facts on the group, and a Product with its own identifier and Offer for each variant.
Shopify’s model maps onto the vocabulary almost one to one. A product can carry up to three options and — since the change Shopify announced on 2025-10-15 — up to 2,048 variants, where the limit used to be 100. Each variant has its own price, its own SKU (which Shopify defines as “a case-sensitive identifier for the product variant in the shop”), its own barcode and its own inventory. The product is the ProductGroup; each variant is a Product node nested under hasVariant, with its own Offer. A single Offer on the product node, describing whichever variant loads first, turns every other variant into a question the markup cannot answer.
The sketch below is Liquid for a product template. It is written for a product whose first option is size; map each option to the schema.org property that actually describes it, and leave out any dimension you cannot map rather than guessing one.
{%- comment -%} Assumes option1 is size. Map each option to its own property. {%- endcomment -%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"name": {{ product.title | json }},
"description": {{ product.description | strip_html | strip | json }},
"url": {{ request.origin | append: product.url | json }},
"brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
"productGroupID": {{ product.id | append: '' | json }},
"variesBy": ["https://schema.org/size"],
"hasVariant": [
{%- for variant in product.variants %}
{
"@type": "Product",
"name": {{ product.title | append: ' - ' | append: variant.title | json }},
"sku": {{ variant.sku | json }},
"size": {{ variant.option1 | json }},
"image": {{ variant.image | default: product.featured_image | image_url: width: 1200 | prepend: 'https:' | json }},
"offers": {
"@type": "Offer",
"url": {{ request.origin | append: variant.url | json }},
"price": {{ variant.price | divided_by: 100.0 | json }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"availability": "https://schema.org/{% if variant.available %}InStock{% else %}OutOfStock{% endif %}"
}
}{% unless forloop.last %},{% endunless %}
{%- endfor %}
]
}
</script>- Currency.
cart.currency.iso_codewrites the ISO 4217 code of the currency the cart is in. Check it matches the currency the page displays; a price with no currency fails the Offer’s required set. - Availability. Bound to
variant.available, never typed. A literalInStockstays true after the variant sells out. - Identifiers. Add
gtinfromvariant.barcodeonly for variants whose barcode is a real GTIN; Shopify’s barcode field also accepts custom values. The product-data guide covers identity in full. - Formatting. Write each price one way, everywhere. The scan compares price values as written, so
25.0in one block and25.00in another are two prices. - Images. A product with no media at all needs a guard around the
imageline; the sketch assumes at least one image.
Fetch, parse,
score — nothing more.
What RankSniperBot does with a product page, in the order it does it. Every step below is what the code does today.
- MSN-01Available
Fetch the raw response
robots.txt is read first and obeyed. Then up to three product pages are fetched as raw HTML. No JavaScript is executed, nothing is logged into and nothing is written. RankSniperBot lists every request a scan makes.
- MSN-02
Parse every block
Each
application/ld+jsonblock is parsed as JSON and walked, including@graphwrappers and array@typevalues. Product, ProductGroup, ProductModel, IndividualProduct and SomeProducts all count as product markup. A block that fails to parse falls back to pattern matching rather than being ignored. - MSN-03
Score the composite
The composite’s schema sub-score adds 40 for Product JSON-LD, 20 for offers, 20 for availability and 20 for shipping details or a return policy — out of 100, blended at 20% against the catalogue checks at 80%. A field counts if any sampled page carries it. GTIN and MPN are detected but not scored in rubric v1.0.
- MSN-04
Score the AIO pillar
Variant-level offers (12 of 108), Price with priceCurrency (12 of 108), Availability in schema (12 of 108) and Schema conflict detection (8 of 108). Offers and availability are scored on every scan and score zero whenever no product markup was found — including when no product page could be read. Price and conflict are excluded when no page could be read. FAQPage, HowTo and QAPage markup feeds AEO instead; the answer-engine guide covers it.
- MSN-05
What it cannot see
Markup a script injects after load. Pages beyond the three sampled. Prices that change by market or by customer after the page is served. The scan reports what the response states, not what a shopper eventually sees.
What it is
JSON-LD in the product page’s HTML restating name, image, price, currency, availability and identifiers.
Why it matters
It is the one place those facts are fields rather than layout. Contradictory or script-only markup leaves a reader without them.
What Rank Sniper observes
Available Product-type nodes, offers, price with currency, availability, shipping or returns, and conflicting values, from up to three pages of raw HTML.
What Rank Sniper does today
Scores what it finds and names the failing checks. It does not edit your theme or your apps.
What remains planned
- Planned Automatic fixes — app-block structured-data markup
- Planned Post-fix verification — what agents actually receive
- Planned Connected scan through the Shopify Admin API
A fix order
that holds up.
The sequence that avoids fixing the same markup twice. Each step is a change you or your developer make in the theme or in app settings; Rank Sniper edits neither today.
- Inventory the sources. Run the
@typelisting on a single-variant product, a multi-variant product and a sold-out product. Switch an app’s structured-data option off on a copy of your theme to see which block is its. - Choose one owner. One Product or ProductGroup per page, written by one source. Remove or disable the other; two partial records are worse than one complete one.
- Bind every value to data. Price and currency from the variant and the cart, availability from
variant.available, identifiers from the variant. No literals. - Group the variants. Move from one Offer to a ProductGroup with an Offer per variant.
- Add shipping and returns from your real policies.
shippingDetailsandhasMerchantReturnPolicyshould describe what your store actually does; a policy the markup invents is a promise made to a customer. - Re-read the raw response, then re-scan. The response is the only version a non-rendering reader gets. The free scan reads it the same way.
How these checks sit among the rest: the AIO pillar, the SEO pillar and every weight on the methodology page. For markup that targets features Google has retired, see our journal’s graveyard tour.
