Adding Product JSON-LD on Shopify — and keeping it in sync
How to add schema.org Product JSON-LD to a Shopify theme, which fields (gtin, sku, offers) matter, and how to keep the markup synced with the live page.

Once product data is enriched with the identifiers and attributes that AI agents and rich results actually need, the remaining job is mechanical: get that data into a <script type="application/ld+json"> block on the product page, and make sure it moves in lockstep with whatever the buyer sees. This guide walks through adding Product JSON-LD to a Shopify theme, the fields worth prioritizing, where each one lives in Shopify's data model, and how to validate the result.
Where JSON-LD lives on a Shopify product page
Liquid has a native structured_data filter — {{ product | structured_data }} — that converts a product object into schema.org JSON-LD automatically (Product if it has no variants, ProductGroup if it has one or more). Recent Dawn releases call this filter directly in the product template, so some current stores already have some Product markup without anyone adding it by hand. But the output is sparse: it covers name, description, image, brand, and a basic offer, and leaves out sku, gtin, mpn, aggregateRating, priceValidUntil, and itemCondition. Older or heavily customized themes may not call the filter at all.
Either way, check what's already on the page before adding anything — "View Page Source" (not the rendered DOM — see validation section), searching for application/ld+json. If a block already exists, whether from the filter or a manual snippet, extend or replace it rather than adding a second one. Two competing Product blocks on the same template make it unclear to Google which values to trust.
To hand-write or extend the block, when the filter's output isn't enough:
- In the Shopify admin, go to Online Store > Themes.
- On the theme you want to edit, click the ⋮ (actions) menu and choose Edit code.
- Add a new snippet, e.g.
snippets/json-ld-product.liquid, containing the JSON-LD block. - Render it from the product template — typically
sections/main-product.liquid— with{% render 'json-ld-product' %}, or fromlayout/theme.liquidif you want it on every page type and gate it with{% if template contains 'product' %}.
This is a code-editor change, not a theme-settings change, so it survives theme customizer edits but will need to be re-applied (or kept in a version-controlled theme repo) if you switch themes entirely.
The fields that matter, and where they come from
{
"@context": "https://schema.org/",
"@type": "Product",
"name": "Trailhead 40L Backpack",
"description": "Weatherproof 40-liter daypack with a padded laptop sleeve and hip belt.",
"sku": "TH-BP-40L-BLK",
"gtin13": "0012345678905",
"brand": {
"@type": "Brand",
"name": "Trailhead Gear"
},
"image": [
"https://cdn.shopify.com/s/files/1/0000/0001/products/trailhead-40l-front.jpg",
"https://cdn.shopify.com/s/files/1/0000/0001/products/trailhead-40l-back.jpg"
],
"offers": {
"@type": "Offer",
"url": "https://example.com/products/trailhead-40l-backpack",
"priceCurrency": "USD",
"price": "129.00",
"priceValidUntil": "2027-07-01",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.7",
"reviewCount": "112"
}
}
Mapped to Shopify's data model:
- name —
product.title. Keep it aligned with the enriched attribute set; a merchandising-optimized title that drifts from the source data reads as inconsistent with the visible H1. - brand —
product.vendor, wrapped as{"@type": "Brand", "name": product.vendor}. If the storefront brand name differs from the internal vendor code (common with a multi-brand catalog), fix that upstream in the PIM, not in the template. - sku —
product.selected_or_first_available_variant.sku, or loopproduct.variantsfor a multi-offer block. Maps directly; no metafield needed. - gtin — Shopify's native
variant.barcodefield, not a metafield. Map it togtin12,gtin13, orgtin14based on the barcode's digit length, since schema.org treats these as distinct properties. Omit the property entirely if a variant has no barcode — an empty string is treated as invalid. - offers.price / priceCurrency —
variant.pricefor the price. For currency,shop.currency(the store's base currency) only works for single-currency stores; with Shopify Markets or another multi-currency setup,variant.pricerenders in the buyer's presentment currency, sopriceCurrencyshould come fromlocalization.country.currency.iso_codeinstead, or the JSON-LD price and currency code won't match for non-default markets. Bind both to live Liquid objects; never hardcode, or a price change leaves stale JSON-LD behind an updated visible price. - offers.availability — derived from
variant.available(InStockvsOutOfStock;LimitedAvailabilityif you track low-stock thresholds). - offers.priceValidUntil — not a native field; set programmatically (commonly
now + 1 year), since Google expects a forward-looking date on the Offer. - aggregateRating — Shopify has no native full review object, but it does define standard metafields for the summary numbers,
reviews.ratingandreviews.rating_count. Some reviews apps (Judge.me, Loox, Yotpo, etc.) write to those; others use their own app-owned metafields or injectAggregateRatingmarkup directly. Check which before adding your own, since duplicate rating markup causes the same conflict as duplicate Product blocks, and make sure the number matches the stars rendered on the page. - mpn — not a standard Shopify field. Store manufacturer part numbers in a metafield (e.g.,
custom.mpn) and referenceproduct.metafields.custom.mpn.
Populating fields Shopify doesn't have natively
For anything beyond title, vendor, SKU, barcode, price, and availability — MPN, material, use-case attributes, additional identifiers — the right home is a metafield, ideally one of Shopify's standard metafield definitions where one exists, so the value stays interoperable rather than becoming a one-off custom key. Reference it as product.metafields.<namespace>.<key>, and guard every optional field with {% if %} so a missing metafield produces an omitted property instead of an empty string or null.
{% if product.metafields.custom.mpn != blank %}
"mpn": {{ product.metafields.custom.mpn | json }},
{% endif %}
Keeping it in sync with the visible page
JSON-LD that drifts from the rendered page is worse than no JSON-LD — it's the textbook case Google's spam policies flag as misleading structured data. Three practical rules keep drift from happening:
- Bind, don't hardcode. Every value should come from a Liquid object (
product.*,variant.*,product.metafields.*) evaluated at render time. If a price or availability changes in the admin, the JSON-LD updates on the next page load automatically. - Drive both surfaces from the same source. If the visible price comes from
variant.price, the JSON-LDoffers.priceshould read the same object — not a cached value or a different app's metafield on a different update schedule. - Re-check after platform or app changes. Installing a reviews app, switching themes, or migrating metafields to a new namespace can silently break or duplicate the block. Re-run validation after any of those, not just at launch.
How to validate
- View source, not the rendered DOM. JSON-LD lives in the initial HTML response, so use "View Page Source" (
Cmd/Ctrl+Uorcurlthe URL) and search forapplication/ld+json. The browser DevTools "Elements" tab shows the live DOM, which can mask a script tag that failed to render or got stripped by a third-party app — always check the raw source first. - Curl it directly to see exactly what a crawler sees, with no client-side JavaScript involved:
curl -s https://example.com/products/trailhead-40l-backpack | grep -A 40 'application/ld+json' - Run it through Google's Rich Results Test, both for the live URL and, if you want to test a change before deploying, for a code snippet pasted directly. It flags missing required properties and type mismatches; it won't always catch two conflicting
Productblocks on the same page, which is why the manual source check above still matters. - Spot-check a sample across variants, not just the default product view — a product with 20 color/size combinations is a common place for the barcode-to-GTIN mapping or the price binding to break on one specific variant while looking fine on the first.
Verified as of July 2026
Field names, the required-vs-recommended split for Offer/AggregateRating properties, and the Shopify Liquid object and filter references above reflect Google's Search Central structured data documentation and Shopify's theme/Liquid documentation as of this writing. Merchant listing eligibility requirements have kept expanding (return policy and shipping details are now part of that surface), and theme defaults vary by theme and version, so re-check the Rich Results Test output after any theme update rather than assuming last year's implementation still passes.
None of this works if the underlying product data — the GTIN, the brand name, the attributes that make mpn or category metafields worth populating — isn't accurate and current in the first place. That's the piece Anglera handles: it keeps product attributes and identifiers enriched and current in whatever PIM or commerce platform already holds the data, so the JSON-LD template above always has something correct to bind to, on Shopify or anywhere else the data needs to travel.
Sources:
- Google Search Central — Product structured data
- Google Search Central — Merchant listing experience structured data
- Shopify.dev — Liquid
structured_datafilter - Shopify.dev — Liquid
productobject reference - Shopify.dev — Liquid
variantobject reference - Shopify Help Center — Metafields
- Shopify.dev — List of standard metafield definitions
