Shopify Liquid Snippets for Clean Product Markup: Best Practices and Implementation

Your Product JSON LD should exactly match what the shopper sees on the page. That means a single canonical Product object with the selected variant priced correctly, a truthful availability, stable identifiers, and an image array that reflects your media. Everything must be server rendered so tools can read it without executing scripts.


Drop in snippet for Product and Offer

Place this file at snippets/product-schema.liquid. It reads the selected variant, falls back to the first available, and emits a compact Product object with attached Offer. Adjust identifier metafield namespaces if needed.

{% comment %}
  snippets/product-schema.liquid
  Emits a single Product with a single Offer that mirrors the buy box.
  Inputs:
    - product
    - selected_variant (optional, variant). If absent, uses product.selected_or_first_available_variant
  Identifier metafields (edit to your namespace):
    - product.metafields.global.gtin
    - product.metafields.global.mpn
  Notes:
    - Keep numbers as strings only when Shopify renders with separators. We strip commas.
    - Ensure priceCurrency matches the active cart currency.
{% endcomment %}

{% assign v = selected_variant | default: product.selected_or_first_available_variant %}
{% assign price_str = v.price | money_without_currency | replace: ',', '' %}
{% assign compare_at_str = v.compare_at_price | money_without_currency | replace: ',', '' %}

{% capture images_json %}
  {% for img in product.images %}
    {{ img | image_url: width: 1200 | absolute_url | json }}{% unless forloop.last %},{% endunless %}
  {% endfor %}
{% endcapture %}

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": "{{ product.url | absolute_url }}#product",
  "url": "{{ product.url | absolute_url }}",
  "name": {{ product.title | json }},
  "description": {{ product.description | strip_html | strip_newlines | truncate: 500 | json }},
  "image": [{{ images_json | strip }}],
  {% if v.sku != blank %}"sku": {{ v.sku | json }},{% endif %}
  {% if product.metafields.global.gtin != blank %}"gtin13": {{ product.metafields.global.gtin | json }},{% endif %}
  {% if product.metafields.global.mpn != blank %}"mpn": {{ product.metafields.global.mpn | json }},{% endif %}
  "brand": { "@type": "Brand", "name": {{ shop.name | json }} },
  "offers": {
    "@type": "Offer",
    "url": "{{ product.url | absolute_url }}",
    "priceCurrency": "{{ cart.currency.iso_code }}",
    "price": "{{ price_str }}",
    {% if compare_at_str != blank and v.compare_at_price > v.price %}"priceValidUntil": "{{ 'now' | date: "%Y-%m-%d" }}",{% endif %}
    "availability": "{% if v.available %}https://schema.org/InStock{% else %}https://schema.org/OutOfStock{% endif %}",
    "itemCondition": "https://schema.org/NewCondition"
  }
}
</script>

Key choices explained

  • One Product object: Keeps a single canonical identity for crawlers. Variant specifics belong in Offer and the visible UI.
  • Selected variant price: Use the same variant the shopper sees to avoid price mismatches.
  • Identifiers: Prefer GTIN and MPN when available. Do not guess values.
  • Image array: Provide multiple absolute URLs. Aim for at least one large image.

Attach AggregateRating without drift

Only add AggregateRating when review stars and count are truly visible on the page. Source the values from the same data that renders the widget. Do not round differently in schema than in the UI.

{% comment %}
  snippets/aggregate-rating.liquid
  Inputs:
    - product
    - reviews_avg (number) and reviews_count (integer) from your review integration
{% endcomment %}

{% if reviews_count and reviews_count != 0 %}
  {% assign normalized_avg = reviews_avg | times: 10 | round | divided_by: 10.0 %}
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "AggregateRating",
    "itemReviewed": { "@id": "{{ product.url | absolute_url }}#product" },
    "ratingValue": "{{ normalized_avg }}",
    "reviewCount": "{{ reviews_count }}"
  }
  </script>
{% endif %}

Optional Review objects for visible texts

Emit Review objects only for review texts that are rendered server side on the page. Limit the sample for page weight.

{% comment %}
  snippets/reviews-jsonld.liquid
  Inputs:
    - product
    - reviews_visible: array of hashes [{ author, body, rating, published_at }]
{% endcomment %}

{% if reviews_visible and reviews_visible.size > 0 %}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    { "@type": "Product", "@id": "{{ product.url | absolute_url }}#product" }
    {% for r in reviews_visible limit: 20 %}
    ,{
      "@type": "Review",
      "itemReviewed": { "@id": "{{ product.url | absolute_url }}#product" },
      "author": { "@type": "Person", "name": {{ r.author | default: "Verified Buyer" | json }} },
      "datePublished": "{{ r.published_at | date: "%Y-%m-%d" }}",
      "reviewBody": {{ r.body | strip_html | strip_newlines | json }},
      "reviewRating": { "@type": "Rating", "ratingValue": "{{ r.rating }}", "bestRating": "5", "worstRating": "1" }
    }
    {% endfor %}
  ]
}
</script>
{% endif %}

Breadcrumbs to reinforce canonical structure

BreadcrumbList helps search engines understand site structure. Use collection context when present, then the product.

{% comment %}
  snippets/breadcrumbs-jsonld.liquid
{% endcomment %}
{% assign trail = '' %}

{% capture items %}
  {
    "@type": "ListItem",
    "position": 1,
    "name": "Home",
    "item": "{{ shop.url }}"
  }
  {% if collection %}
  ,{
    "@type": "ListItem",
    "position": 2,
    "name": {{ collection.title | json }},
    "item": "{{ collection.url | absolute_url }}"
  }
  ,{
    "@type": "ListItem",
    "position": 3,
    "name": {{ product.title | json }},
    "item": "{{ product.url | absolute_url }}"
  }
  {% else %}
  ,{
    "@type": "ListItem",
    "position": 2,
    "name": {{ product.title | json }},
    "item": "{{ product.url | absolute_url }}"
  }
  {% endif %}
{% endcapture %}

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [{{ items | strip }}]
}
</script>

Composition in your product template

Call the snippets from main-product.liquid or your equivalent section. Ensure they render on the server before any interactive hydration.

{%- comment -%} main-product.liquid excerpt {%- endcomment -%}
{%- assign selected_variant = product.selected_or_first_available_variant -%}

{%- render 'product-schema', product: product, selected_variant: selected_variant -%}

{%- comment -%} Provide review values from your integration or metafields {%- endcomment -%}
{%- assign reviews_avg = product.metafields.reviews.avg -%}
{%- assign reviews_count = product.metafields.reviews.count -%}
{%- render 'aggregate-rating', product: product, reviews_avg: reviews_avg, reviews_count: reviews_count -%}

{%- comment -%} If you render server side review texts into the HTML, also emit Review objects {%- endcomment -%}
{%- render 'reviews-jsonld', product: product, reviews_visible: reviews_visible -%}

{%- render 'breadcrumbs-jsonld', product: product, collection: collection -%}

Best practices checklist

  • Server render all JSON LD. Do not rely on client injection.
  • Emit a single Product object per PDP. Avoid multiple Product nodes.
  • Use the selected variant for price and availability so schema equals UI.
  • Keep identifiers stable. Populate GTIN or MPN only if you have authoritative values.
  • Ensure absolute URLs for images, product, and breadcrumbs.
  • Keep numbers unformatted in JSON. Strip thousand separators.
  • Match locale and currency to the current storefront.
  • Only include Review and AggregateRating when the data is truly visible.
  • Audit weekly for mismatches between UI and schema. Log changes with dateModified on page deploy.

Implementation options compared

OptionHow it worksProsConsSSRDifficultyBest for
Theme only snippetsPure Liquid snippets render JSON LD directly in the product section.Fast, transparent, easy to review in View Source.Logic can sprawl if many conditions. Requires discipline.YesEasyMost stores on Online Store 2.0.
Metafield drivenPre compute values into metafields then render with minimal Liquid.Stable, simple output. Easy parity across themes.Background jobs needed to keep fields fresh.YesModerateTeams with light engineering and steady catalogs.
App proxy or section render cacheFetch normalized data server side and inline JSON in the HTML.Centralized logic. Works across multiple storefronts.More infrastructure. Cache invalidation needs care.YesHardMulti store or multilingual builds.

QA steps before deployment

  • View Source. Confirm Product JSON is present without scripts.
  • Compare price, currency, and availability between UI and schema.
  • Check a product with no identifiers. Ensure GTIN or MPN are omitted entirely.
  • Check a discounted product. Confirm priceValidUntil only appears when valid.
  • Switch locales and currencies. Confirm proper currency codes.
  • Test a product with zero reviews. Ensure no AggregateRating or Review is emitted.