JSON-LD is the structured-data format Google reads most easily: a <script type="application/ld+json"> block that describes the page in schema.org terms. On a Shopify store, writing it is the easy part. The harder part is knowing what the theme already prints, and writing Liquid that survives the first product title with a quote mark in it.
Which schema types a Shopify store needs
Four types cover a normal store. Product carries price, stock and identifiers. The other three describe the store around it. I read the output of Shopify's two reference themes on their demo stores on 2026-09-16: Dawn prints Organization on every page, WebSite on the home page and ProductGroup on product pages; Horizon prints Organization and ProductGroup28. Neither prints BreadcrumbList or any return or shipping policy.
| Type | Page | Where in the theme | Dawn / Horizon print it? |
|---|---|---|---|
| Product or ProductGroup, with its Offer | Every product page | The product section: sections/main-product.liquid (Dawn), sections/product-information.liquid (Horizon) | Yes, through {{ product | structured_data }} |
| Organization (OnlineStore) | Home page | layout/theme.liquid, or the block in sections/header.liquid | A short one: name, logo, url (Dawn adds sameAs) |
| MerchantReturnPolicy and shipping policy | Inside Organization, or inside each Offer | With the Organization block | No |
| WebSite | Home page | layout/theme.liquid | Dawn only, with a SearchAction Google no longer uses |
| BreadcrumbList | Product and collection pages | A snippet rendered from layout/theme.liquid | No |
Skip FAQPage for rich results. Google stopped showing FAQ rich results on 7 May 202611, so this tool doesn't generate it.
Check what your theme already prints
Shopify's structured_data filter outputs "a schema.org Product if they have no variants, and a ProductGroup if they have one or more variants"14. Dawn has used it since version 1526 and Horizon uses it in its product section27. On Dawn's demo store it produced name, description, brand, productGroupID and url, and per variant a name, SKU, gtin when the barcode field is filled, an image and an Offer with price, currency, availability and URL. It left out variesBy, colour and size, itemCondition, shippingDetails and hasMerchantReturnPolicy28.
Open a product page, view the source and search for application/ld+json. Count the Product and ProductGroup blocks. If there is more than one (the theme's, plus one from a reviews or SEO app, plus one someone pasted in), Google gets several descriptions of the same product, and when they disagree on price or currency you don't control which one it believes. Keep one owner per type. Schema apps vs theme Liquid covers how to pick that owner.
That leaves two sensible routes:
- Keep the theme's Product markup and add policies to your Organization. Google reads store-wide return and shipping policies from Organization markup (shipping since November 2025)5 and applies them to products that don't carry their own. This fills the two recommended merchant-listing properties the filter leaves out without touching product code. Use the Organization or Return & shipping policy tab.
- Replace the theme's Product markup when you need what the filter doesn't print:
variesBywith colour and size per variant, a condition other than new, or a policy that differs for one product. Use Liquid mode on the Product tab and swap the theme's one line for the snippet, so there's still one Product block.
Required and recommended properties
Shopify product pages sell things, so Google treats them as merchant listings, which are stricter than product snippets. A product snippet needs a name plus one of offers, review or aggregateRating, and allows a price of 02. A merchant listing needs more1:
| Type | Required | Recommended |
|---|---|---|
| Product (merchant listing) | name, image, offers | description, brand.name (one brand), gtin / gtin8–gtin14, mpn, sku (no whitespace), color, size, material, pattern, aggregateRating, review |
| Offer | price greater than zero, priceCurrency | availability, itemCondition, url, priceValidUntil, shippingDetails, hasMerchantReturnPolicy |
| ProductGroup3 | name; and productGroupID here or inProductGroupWithID on each variant | hasVariant, variesBy (color, size, material, pattern, suggestedAge, suggestedGender), url for single-page stores like Shopify, brand, description |
| MerchantReturnPolicy4 | applicableCountry and returnPolicyCategory, plus merchantReturnDays for a finite window. Or just merchantReturnLink. | returnMethod, returnFees, returnShippingFeesAmount (only with ReturnShippingFees), refundType, returnPolicyCountry |
| OfferShippingDetails (inside an Offer) | For the shipping enhancement: shippingRate (currency must match the offer), shippingDestination, deliveryTime with handling and transit min/max in days | One shippingRate and one deliveryTime per object |
| ShippingService (on Organization)5 | shippingConditions | handlingTime; in each ShippingConditions: shippingDestination (none means worldwide), shippingRate, transitTime, orderValue, doesNotShip |
| Organization6 | None | name, url, logo (112 x 112 px or larger), sameAs, contactPoint, email, telephone, address, legalName, description, hasMerchantReturnPolicy, hasShippingService |
| BreadcrumbList7 | Two or more ListItems, each with position, name and item (item optional on the last) | None |
| WebSite (site names)8 | name, url of the home page | alternateName |
Older generators still offer a sitelinks search box. Google removed it from results in November 2024, so there's no SearchAction here9. Leftover markup does no harm, Google says, so you don't need to delete Dawn's. WebSite markup also only counts on the home page: Google doesn't support site names for subdirectories.
An example, annotated
This is the Product tab's output for a shade sold in two colours and widths, with policies set store-wide. It's generated at build time by the same code the tool runs.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "ProductGroup",
"name": "Linen Roman Shade",
"description": "Hand-finished linen Roman shade with a cotton lining and a cordless lift.",
"url": "https://hollis-coastal.example/products/linen-roman-shade",
"brand": {
"@type": "Brand",
"name": "Hollis Coastal"
},
"productGroupID": "HC-LRS",
"material": "Linen",
"variesBy": [
"https://schema.org/color",
"https://schema.org/size"
],
"hasVariant": [
{
"@type": "Product",
"name": "Linen Roman Shade - Natural / 36 in",
"sku": "HC-LRS-N36",
"gtin12": "012345678905",
"image": "https://hollis-coastal.example/cdn/shop/files/linen-shade-natural.jpg",
"color": "Natural",
"size": "36 in",
"offers": {
"@type": "Offer",
"url": "https://hollis-coastal.example/products/linen-roman-shade?variant=1001",
"price": 129,
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition"
}
},
{
"@type": "Product",
"name": "Linen Roman Shade - Flax / 48 in",
"sku": "HC-LRS-F48",
"image": "https://hollis-coastal.example/cdn/shop/files/linen-shade-flax.jpg",
"color": "Flax",
"size": "48 in",
"offers": {
"@type": "Offer",
"url": "https://hollis-coastal.example/products/linen-roman-shade?variant=1002",
"price": 149,
"priceCurrency": "USD",
"availability": "https://schema.org/OutOfStock",
"itemCondition": "https://schema.org/NewCondition"
}
}
]
}
</script>
variesBylists colour and size because those differ between the variants. Material is the same, so it sits once on the group.- Each variant has its own URL with
?variant=. Google requires a URL that preselects each variant, and Shopify variant URLs work that way. - The first variant has a 12-digit barcode, so it's written as
gtin12, the most specific property. The second has none and gets no GTIN rather than a made-up one. - Prices are plain numbers with a dot and no currency symbol, which is how schema.org defines
price13. - No shipping or return policy on the offers: those live on the Organization, below.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "OnlineStore",
"@id": "https://hollis-coastal.example/#organization",
"name": "Hollis Coastal",
"url": "https://hollis-coastal.example/",
"logo": "https://hollis-coastal.example/cdn/shop/files/hollis-logo-512.png",
"sameAs": [
"https://www.instagram.com/holliscoastal",
"https://www.pinterest.com/holliscoastal"
],
"email": "[email protected]",
"contactPoint": {
"@type": "ContactPoint",
"contactType": "customer service",
"email": "[email protected]"
},
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": "US",
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": 30,
"returnMethod": "https://schema.org/ReturnByMail",
"returnFees": "https://schema.org/FreeReturn",
"refundType": "https://schema.org/FullRefund"
},
"hasShippingService": {
"@type": "ShippingService",
"handlingTime": {
"@type": "ServicePeriod",
"duration": {
"@type": "QuantitativeValue",
"minValue": 0,
"maxValue": 1,
"unitCode": "DAY"
}
},
"shippingConditions": {
"@type": "ShippingConditions",
"shippingDestination": {
"@type": "DefinedRegion",
"addressCountry": "US"
},
"shippingRate": {
"@type": "MonetaryAmount",
"value": 0,
"currency": "USD"
},
"transitTime": {
"@type": "ServicePeriod",
"duration": {
"@type": "QuantitativeValue",
"minValue": 2,
"maxValue": 5,
"unitCode": "DAY"
}
}
}
}
}
</script>
ShippingService puts handling time on the service and transit time on each ShippingConditions. That's a different shape from the offer-level OfferShippingDetails, which nests both inside deliveryTime. Mixing the two shapes is a common validator error. For several shipping zones, repeat ShippingConditions once per zone; the shippingDetails guide maps zones from Shopify's shipping settings.
The Liquid version, explained
Static JSON-LD is fine for one page. For a catalog you want Liquid that reads each product. This is the Product tab in Liquid mode, saved as snippets/product-schema.liquid. The theme.liquid vs section files guide covers placement for the other types.
{%- comment -%}
snippets/product-schema.liquid
Generated by the ShopifyRanked schema generator:
https://shopifyranked.com/tools/shopify-schema-generator/
Where it goes: the product section (Dawn: sections/main-product.liquid,
Horizon: sections/product-information.liquid). Replace the theme's
<script type="application/ld+json">{{ product | structured_data }}</script>
with
{% render 'product-schema', product: product %}
so the page carries one Product entity, not two.
Every string goes through | json, which adds the quotes and escapes
quotes, backslashes and line breaks. Never use | escape inside JSON-LD.
{%- endcomment -%}
{%- assign sr_color_names = "color,colour" | split: ',' -%}
{%- assign sr_size_names = "size" | split: ',' -%}
{%- assign sr_material_names = "material" | split: ',' -%}
{%- assign sr_url = request.origin | append: product.url -%}
<script type="application/ld+json">
{
"@context": "https://schema.org",
{%- if product.has_only_default_variant %}
"@type": "Product",
{%- else %}
"@type": "ProductGroup",
"productGroupID": "{{ product.id }}",
{%- endif %}
"@id": {{ sr_url | append: '#product' | json }},
"name": {{ product.title | json }},
"url": {{ sr_url | json }},
"description": {{ product.description | strip_html | strip | json }},
{%- if product.vendor != blank %}
"brand": { "@type": "Brand", "name": {{ product.vendor | json }} },
{%- endif %}
{%- if product.has_only_default_variant %}
{%- assign variant = product.selected_or_first_available_variant -%}
{%- if variant.sku != blank %}
"sku": {{ variant.sku | json }},
{%- endif %}
{%- assign sr_bc = variant.barcode | strip -%}
{%- assign sr_bc_rest = sr_bc | remove: '0' | remove: '1' | remove: '2' | remove: '3' | remove: '4' | remove: '5' | remove: '6' | remove: '7' | remove: '8' | remove: '9' -%}
{%- if sr_bc != blank and sr_bc_rest == blank -%}
{%- case sr_bc.size -%}
{%- when 8 %}
"gtin8": {{ sr_bc | json }},
{%- when 12 %}
"gtin12": {{ sr_bc | json }},
{%- when 13 %}
"gtin13": {{ sr_bc | json }},
{%- when 14 %}
"gtin14": {{ sr_bc | json }},
{%- endcase -%}
{%- endif -%}
{%- if product.featured_image %}
"image": [
{%- for image in product.images limit: 5 %}
{{ image | image_url: width: 1200 | prepend: 'https:' | json }}{% unless forloop.last %},{% endunless %}
{%- endfor %}
],
{%- endif %}
"offers": {
"@type": "Offer",
"url": {{ request.origin | append: variant.url | json }},
"price": {{ variant.price | divided_by: 100.0 }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"availability": {% if variant.available %}"https://schema.org/InStock"{% else %}"https://schema.org/OutOfStock"{% endif %},
"itemCondition": "https://schema.org/NewCondition"
}
{%- else %}
{%- assign sr_varies = '' -%}
{%- for option in product.options_with_values -%}
{%- assign sr_name = option.name | downcase | strip -%}
{%- if sr_color_names contains sr_name -%}
{%- assign sr_varies = sr_varies | append: 'https://schema.org/color,' -%}
{%- elsif sr_size_names contains sr_name -%}
{%- assign sr_varies = sr_varies | append: 'https://schema.org/size,' -%}
{%- elsif sr_material_names contains sr_name -%}
{%- assign sr_varies = sr_varies | append: 'https://schema.org/material,' -%}
{%- endif -%}
{%- endfor -%}
{%- if sr_varies != blank %}
"variesBy": {{ sr_varies | split: ',' | json }},
{%- endif %}
"hasVariant": [
{%- for variant in product.variants %}
{
"@type": "Product",
"@id": {{ request.origin | append: variant.url | append: '#variant' | json }},
"name": {{ product.title | append: ' - ' | append: variant.title | json }},
{%- if variant.sku != blank %}
"sku": {{ variant.sku | json }},
{%- endif %}
{%- assign sr_bc = variant.barcode | strip -%}
{%- assign sr_bc_rest = sr_bc | remove: '0' | remove: '1' | remove: '2' | remove: '3' | remove: '4' | remove: '5' | remove: '6' | remove: '7' | remove: '8' | remove: '9' -%}
{%- if sr_bc != blank and sr_bc_rest == blank -%}
{%- case sr_bc.size -%}
{%- when 8 %}
"gtin8": {{ sr_bc | json }},
{%- when 12 %}
"gtin12": {{ sr_bc | json }},
{%- when 13 %}
"gtin13": {{ sr_bc | json }},
{%- when 14 %}
"gtin14": {{ sr_bc | json }},
{%- endcase -%}
{%- endif -%}
{%- for option in product.options_with_values -%}
{%- assign sr_name = option.name | downcase | strip -%}
{%- assign sr_value = variant.options[forloop.index0] -%}
{%- if sr_color_names contains sr_name %}
"color": {{ sr_value | json }},
{%- elsif sr_size_names contains sr_name %}
"size": {{ sr_value | json }},
{%- elsif sr_material_names contains sr_name %}
"material": {{ sr_value | json }},
{%- endif -%}
{%- endfor %}
{%- assign sr_image = variant.featured_image | default: product.featured_image -%}
{%- if sr_image %}
"image": {{ sr_image | image_url: width: 1200 | prepend: 'https:' | json }},
{%- endif %}
"offers": {
"@type": "Offer",
"url": {{ request.origin | append: variant.url | json }},
"price": {{ variant.price | divided_by: 100.0 }},
"priceCurrency": {{ cart.currency.iso_code | json }},
"availability": {% if variant.available %}"https://schema.org/InStock"{% else %}"https://schema.org/OutOfStock"{% endif %},
"itemCondition": "https://schema.org/NewCondition"
}
}{% unless forloop.last %},{% endunless %}
{%- endfor %}
]
{%- endif %}
}
</script>
What each part is doing, and why:
- Strings go through
| json. The filter adds the surrounding quotes and escapes quotes inside the value15, so a title like12" Walnut Boardor a description with line breaks still produces valid JSON. That's why there are no quote marks around{{ product.title | json }}. - Prices are divided by
100.0. Shopify gives prices in the currency's subunit, so $12.50 arrives as 1250, and a currency without subunits is multiplied too: 1000 yen arrives as 10000020. Dividing by100.0returns a decimal; dividing by100would return a whole number and drop the cents19. The result is an unquoted JSON number. - The currency comes from the cart.
variant.priceis in the shopper's local currency, and Shopify's own guidance is to setpriceCurrencyfromcart.currency.iso_code, not the shop's currency22. With Shopify Markets,shop.currencywould label a euro price as dollars. - Availability comes from
variant.available21, one Offer per variant. - URLs are absolute.
variant.urlis relative (/products/handle?variant=123).request.originadds the protocol and host23.image_urlreturns a protocol-relative//URL24, so the snippet prependshttps:, the same as Dawn and Horizon do. - The barcode becomes a GTIN only when it is 8, 12, 13 or 14 digits. Shopify's barcode field takes any text, and some stores keep internal codes there. Liquid can't check a check digit cheaply, so run your barcodes through the checker under the generator first. More on mapping in the GTIN guide.
- It renders with
{% render 'product-schema', product: product %}. A rendered snippet can read global objects such asrequestandcart25; passingproductkeeps it working in featured-product sections too.
Common Shopify schema mistakes
Two Product blocks on one page
The theme's structured_data block plus a block from an app or a hand-written snippet. Remove all but one. If an app owns Product, remove the theme's line; if the theme owns it, turn off the app's product markup.
| escape inside JSON-LD
escape is an HTML filter16. It turns " into ", so a JSON parser sees the literal text " in your product name, and it leaves line breaks and backslashes alone, which breaks the JSON outright. A description with a line break inside "{{ product.description | strip_html | escape }}" produces a string JSON can't parse. Use {{ product.description | strip_html | json }} with no quotes around it.
Prices with thousands separators or a currency symbol
money_without_currency formats a price for display using the store's currency format setting17. Depending on that setting, 1134.65 comes out as 1,134.65, 1.134,65 or 1 134,6518. schema.org wants a dot for decimals and no separators13. money adds a currency symbol on top. Use divided_by: 100.0 for markup and keep the money filters for what shoppers read.
Missing or invalid GTINs
A GTIN is 8, 12, 13 or 14 digits with a valid GS1 check digit12. Typos, truncated barcodes and internal codes in the barcode field fail that test. If a product has no GTIN (handmade, vintage, own brand without GS1 registration), leave the field empty. An invented one is worse than none.
Review stars you can't show
Rating markup has to match reviews shoppers can see on the page. Reviews copied from other sites and fake or undisclosed incentivised reviews are out, and reviews of your own business marked up on your own Organization are self-serving and ineligible for stars10. Customer reviews of a product, shown on that product's page, are fine. Most review apps already print this markup, which is why the rating fields here stay off until you tick the box.
Hard-coded currency, dollar-only shipping
A "priceCurrency": "USD" typed into the theme is wrong for every shopper who sees euros. The same goes for a fixed shipping rate: Google requires the rate's currency to match the offer's. The Liquid snippet reads the currency from the cart, and it prints a fixed, non-zero shipping rate only when the cart is in the currency you typed that rate in.
How to test it
- Check the tool's panel: no errors, and the JSON check passes (static mode).
- Publish to a duplicate theme first, preview it, and view the page source to confirm there's exactly one block per type.
- Run a live product URL through Google's Rich Results Test. It shows product snippet and merchant listing results separately. The Rich Results Test guide lists what each template should show.
- Run the home page through the Schema Markup Validator too. The Rich Results Test doesn't check site names8.
What this generator doesn't do
- It doesn't read your store. Nothing is fetched and nothing you type leaves the browser.
- One shipping zone and one return policy at a time. Free-shipping thresholds, several zones and seasonal return windows need the extra
ShippingConditionsand override objects Google documents. - No individual Review objects. Let the review app own those.
- Liquid mode maps colour, size and material by option name. Options with other names aren't marked up.
- It can't check Liquid until Shopify renders it. Test a live URL after publishing.
FAQ
Does Shopify add schema markup automatically?
For products, yes. Current Shopify themes print Product or ProductGroup markup through the structured_data filter, and Dawn and Horizon also print a short Organization block. They don't print return or shipping policies or BreadcrumbList. View the source of a product page to see what yours does.
Should I use | escape or | json in JSON-LD?
| json, without quotes around the tag. | escape is for HTML: it writes quotes as " and lets line breaks through, which breaks the JSON.
How do I output the price correctly in Liquid?
{{ variant.price | divided_by: 100.0 }}, unquoted. Shopify prices are in subunits, including for yen. Don't use money or money_without_currency: they follow the store's display format, which can add symbols and thousands separators.
Do I need a GTIN?
It's recommended, not required. Add it when the product has a real barcode, and make sure the check digit is valid. Leave it empty for products that don't have one.
Do return and shipping policies go on the product or the Organization?
Either. Store-wide policies on the Organization are less markup and cover every product. Product-level markup overrides them for one product. Settings in Merchant Center override both.
Is this the same as a Shopify section schema generator?
No. A section {% schema %} block defines the settings a section shows in the theme editor. It has nothing to do with search results. This tool writes schema.org structured data.
Why is there no FAQPage or sitelinks search box option?
Google stopped showing FAQ rich results on 7 May 2026 and removed the sitelinks search box in November 2024. The markup still validates, but it no longer changes how your listing looks.