Skip to content

Free tool · Structured data

Shopify schema markup generator

Build JSON-LD for a Shopify store: Product (variants as ProductGroup), Organization with its return and shipping policy, WebSite and BreadcrumbList. Type your values for static markup, or switch to Liquid for a theme snippet that reads them from your products.

Free, no signup Built by Billy Reiner Updated

Schema type
Output
Product

One per line. Full https:// URLs.

Digits and a dot, e.g. 1299.00.

ISO 4217 code: USD, EUR, CAD…

Only for a sale price that ends. YYYY-MM-DD.

Condition and policies

Store-wide is less markup. Product-level overrides it for one product.

Reviews

Output

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Linen Roman Shade",
  "description": "Hand-finished linen Roman shade with a cotton lining and a cordless lift.",
  "image": "https://hollis-coastal.example/cdn/shop/files/linen-shade.jpg",
  "url": "https://hollis-coastal.example/products/linen-roman-shade",
  "brand": {
    "@type": "Brand",
    "name": "Hollis Coastal"
  },
  "sku": "HC-LRS-36",
  "gtin12": "012345678905",
  "color": "Natural",
  "size": "36 in",
  "material": "Linen",
  "offers": {
    "@type": "Offer",
    "url": "https://hollis-coastal.example/products/linen-roman-shade",
    "price": 129,
    "priceCurrency": "USD",
    "availability": "https://schema.org/InStock",
    "itemCondition": "https://schema.org/NewCondition"
  }
}
</script>

Paste it into the product template, or into a Custom Liquid block on the product page.

Checks

    Then test the live page: Rich Results Test · Schema Markup Validator

    GTIN check-digit checker

    Paste the barcode column from a product export. Nothing leaves your browser.

    Checks GTIN-8, UPC (GTIN-12), EAN (GTIN-13) and GTIN-14 check digits.

      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.

      TypePageWhere in the themeDawn / Horizon print it?
      Product or ProductGroup, with its OfferEvery product pageThe product section: sections/main-product.liquid (Dawn), sections/product-information.liquid (Horizon)Yes, through {{ product | structured_data }}
      Organization (OnlineStore)Home pagelayout/theme.liquid, or the block in sections/header.liquidA short one: name, logo, url (Dawn adds sameAs)
      MerchantReturnPolicy and shipping policyInside Organization, or inside each OfferWith the Organization blockNo
      WebSiteHome pagelayout/theme.liquidDawn only, with a SearchAction Google no longer uses
      BreadcrumbListProduct and collection pagesA snippet rendered from layout/theme.liquidNo

      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:

      1. 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.
      2. Replace the theme's Product markup when you need what the filter doesn't print: variesBy with 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:

      TypeRequiredRecommended
      Product (merchant listing)name, image, offersdescription, brand.name (one brand), gtin / gtin8gtin14, mpn, sku (no whitespace), color, size, material, pattern, aggregateRating, review
      Offerprice greater than zero, priceCurrencyavailability, itemCondition, url, priceValidUntil, shippingDetails, hasMerchantReturnPolicy
      ProductGroup3name; and productGroupID here or inProductGroupWithID on each varianthasVariant, variesBy (color, size, material, pattern, suggestedAge, suggestedGender), url for single-page stores like Shopify, brand, description
      MerchantReturnPolicy4applicableCountry 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 daysOne shippingRate and one deliveryTime per object
      ShippingService (on Organization)5shippingConditionshandlingTime; in each ShippingConditions: shippingDestination (none means worldwide), shippingRate, transitTime, orderValue, doesNotShip
      Organization6Nonename, url, logo (112 x 112 px or larger), sameAs, contactPoint, email, telephone, address, legalName, description, hasMerchantReturnPolicy, hasShippingService
      BreadcrumbList7Two or more ListItems, each with position, name and item (item optional on the last)None
      WebSite (site names)8name, url of the home pagealternateName

      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.

      JSON-LD ProductGroup with two variants
      <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>
      • variesBy lists 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.
      JSON-LD OnlineStore with store-wide return and shipping policy
      <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.

      Liquid snippets/product-schema.liquid
      {%- 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 like 12" Walnut Board or 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 by 100.0 returns a decimal; dividing by 100 would return a whole number and drop the cents19. The result is an unquoted JSON number.
      • The currency comes from the cart. variant.price is in the shopper's local currency, and Shopify's own guidance is to set priceCurrency from cart.currency.iso_code, not the shop's currency22. With Shopify Markets, shop.currency would label a euro price as dollars.
      • Availability comes from variant.available21, one Offer per variant.
      • URLs are absolute. variant.url is relative (/products/handle?variant=123). request.origin adds the protocol and host23. image_url returns a protocol-relative // URL24, so the snippet prepends https:, 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 as request and cart25; passing product keeps 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 &quot;, so a JSON parser sees the literal text &quot; 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

      1. Check the tool's panel: no errors, and the JSON check passes (static mode).
      2. Publish to a duplicate theme first, preview it, and view the page source to confirm there's exactly one block per type.
      3. 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.
      4. 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 ShippingConditions and 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 &quot; 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.