Technical SEO / Shopify

Shopify OG Tags & Schema.org: An Implementation Guide for Engineers

Every snippet below is copy-paste ready and tells you which Liquid file it goes in. No theory, no keyword primer — just the markup that controls how your store renders in link previews and in Google's result set.

Home › Shopify OG Tags & Schema.org

Two things on a Shopify store are pure engineering problems with disproportionate payoff: Open Graph tags, which decide what a human sees when your product URL is pasted into WhatsApp, Slack, iMessage or LinkedIn; and structured data, which decides whether Google renders your listing as a bare blue link or as a price-plus-stars-plus-availability result. Both are markup, both are testable, neither depends on content strategy.

Everything here assumes an Online Store 2.0 theme (Dawn or a derivative). On a customised vintage theme the file names differ, but the Liquid objects are identical.

1. Open Graph tags

Open Graph is a small vocabulary of <meta property="og:*"> tags read by scrapers when a URL is shared. Absent them, the scraper guesses — usually badly: it grabs your first DOM image (often a payment-icon sprite), truncates whatever text it finds, and your product link renders as a grey box. That's the click-through rate on every link your customers and your paid social ever share.

Five tags carry essentially all the weight for a product:

TagControlsNotes for a product page
og:titleHeadline of the preview cardProduct name. Keep it under ~60 chars; don't append your store name twice.
og:descriptionBody text under the headline~110–160 chars. Strip HTML from the product body — see the filter chain below.
og:imageThe preview imageThe single highest-impact tag. Needs an absolute URL. 1200×630 is the safe target.
og:urlCanonical identity of the shared objectMust be the canonical product URL, not the collection-scoped one. See §3.
og:typeObject typeproduct on product templates, website elsewhere, article on blog posts.

Where it goes in a Shopify theme

Don't paste raw meta tags into theme.liquid. Dawn already renders a snippet for this, and a second set produces duplicates that scrapers resolve unpredictably. Check first:

Terminal — Shopify CLI

shopify theme pull
grep -rn "og:image" ./snippets ./layout ./sections

On a stock Dawn theme that returns snippets/meta-tags.liquid, which layout/theme.liquid pulls into the <head> with a single line:

layout/theme.liquid — inside <head>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <link rel="canonical" href="{{ canonical_url }}">

  {% render 'meta-tags' %}   <!-- ← OG + Twitter live here -->

  {{ content_for_header }}
</head>
Worth knowing

{{ content_for_header }} is Shopify-injected and must stay in the <head>, but it does not emit Open Graph tags. It carries analytics, app scripts and the preview bar. OG is 100% your theme's responsibility.

The snippet

Replace snippets/meta-tags.liquid with this — it branches per template and handles the two things people get wrong (absolute image URLs and unescaped descriptions):

snippets/meta-tags.liquid

{%- liquid
  assign og_title = page_title
  assign og_url   = canonical_url
  assign og_type  = 'website'
  assign og_desc  = page_description | default: shop.description

  if request.page_type == 'product'
    assign og_type  = 'product'
    assign og_title = product.title
    assign og_image = product.featured_image
    assign og_desc  = product.description | strip_html | truncate: 155
  elsif request.page_type == 'article'
    assign og_type  = 'article'
    assign og_title = article.title
    assign og_image = article.image
    assign og_desc  = article.excerpt_or_content | strip_html | truncate: 155
  elsif request.page_type == 'collection'
    assign og_image = collection.image
    assign og_desc  = collection.description | strip_html | truncate: 155
  endif

  unless og_image
    assign og_image = settings.share_image
  endunless
-%}

<meta property="og:site_name" content="{{ shop.name | escape }}">
<meta property="og:type"      content="{{ og_type }}">
<meta property="og:title"     content="{{ og_title | escape }}">
<meta property="og:description" content="{{ og_desc | escape }}">
<meta property="og:url"       content="{{ og_url }}">
<meta property="og:locale"    content="{{ request.locale.iso_code }}">

{%- if og_image -%}
  <meta property="og:image" content="https:{{ og_image | image_url: width: 1200 }}">
  <meta property="og:image:secure_url" content="https:{{ og_image | image_url: width: 1200 }}">
  <meta property="og:image:width"  content="1200">
  <meta property="og:image:height" content="{{ 1200 | divided_by: og_image.aspect_ratio | round }}">
  <meta property="og:image:alt"    content="{{ og_image.alt | default: og_title | escape }}">
{%- endif -%}

{%- if request.page_type == 'product' -%}
  {%- assign v = product.selected_or_first_available_variant -%}
  <meta property="product:price:amount"   content="{{ v.price | divided_by: 100.0 }}">
  <meta property="product:price:currency" content="{{ cart.currency.iso_code }}">
  <meta property="product:availability"   content="{% if v.available %}in stock{% else %}out of stock{% endif %}">
{%- endif -%}

<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ og_title | escape }}">
<meta name="twitter:description" content="{{ og_desc | escape }}">
{%- if og_image -%}
  <meta name="twitter:image" content="https:{{ og_image | image_url: width: 1200 }}">
{%- endif -%}

The four failure modes

  1. Protocol-relative image URLs. image_url returns //cdn.shopify.com/…. Fine in a browser, broken in half the scrapers, which fetch with no base URL. The https: prefix above isn't decorative.
  2. Using img_url. Deprecated. image_url also requires an explicit width or height.
  3. Unescaped descriptions. A product body containing a straight double quote terminates the content attribute and silently corrupts every tag after it. | escape on every interpolated attribute.
  4. Cached previews. Scrapers cache for days. After changing tags, force a re-scrape in Facebook's Sharing Debugger and LinkedIn's Post Inspector — otherwise you'll conclude your fix didn't work when it did.
Quick check

curl -s https://your-store.com/products/handle | grep 'og:' — confirms what scrapers actually receive. Trust this over a rendered browser view: some apps inject OG tags client-side, where no scraper will ever see them.

2. Schema.org structured data

Structured data is a machine-readable description of the page, embedded as JSON-LD in a <script type="application/ld+json"> block. Google uses it to decide eligibility for rich results — the price, availability, stars and breadcrumb trail under your listing. Eligibility isn't a guarantee, but without the markup it's a hard no.

Four types matter, in order of return on effort: Product + Offer, BreadcrumbList, AggregateRating, Organization. The vocabulary is defined at schema.org; what Google actually consumes is a subset, documented in its product structured-data reference.

Check what your theme already emits

Dawn ships partial Product schema in sections/main-product.liquid — decent, but usually missing brand, aggregateRating, @id and multi-variant offers. Find it before writing anything:

Terminal

grep -rn "application/ld+json" ./sections ./snippets ./layout ./templates
The single most common bug I find

Duplicate, conflicting Product nodes. Your theme emits one, your reviews app (Judge.me, Loox, Okendo) injects a second, a "SEO booster" app adds a third. Google parses all of them, sees two prices for one URL, and either picks the wrong one or drops the rich result. Before adding markup, curl the page and count your ld+json blocks. More than one Product node means consolidating to a single source of truth — disable the app's schema output rather than deleting the theme's.

Product + Offer

The core snippet. Prices in Liquid are integers in cents, hence divided_by: 100.0 — keep the decimal, or Liquid does integer division and your £24.99 becomes £24.

sections/main-product.liquid — near the bottom

{%- liquid
  assign v = product.selected_or_first_available_variant
  assign product_url = shop.url | append: product.url
-%}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "@id": {{ product_url | append: '#product' | json }},
  "name": {{ product.title | json }},
  "description": {{ product.description | strip_html | truncate: 500 | json }},
  "url": {{ product_url | json }},
  "sku": {{ v.sku | json }},
  "mpn": {{ v.barcode | json }},
  "brand": {
    "@type": "Brand",
    "name": {{ product.vendor | json }}
  },
  "image": [
    {%- for image in product.images limit: 5 -%}
      {{ image | image_url: width: 1200 | prepend: "https:" | json }}{%- unless forloop.last -%},{%- endunless -%}
    {%- endfor -%}
  ],
  "offers": {
    "@type": "Offer",
    "url": {{ product_url | json }},
    "price": {{ v.price | divided_by: 100.0 | json }},
    "priceCurrency": {{ cart.currency.iso_code | json }},
    "availability": "https://schema.org/{% if v.available %}InStock{% else %}OutOfStock{% endif %}",
    "itemCondition": "https://schema.org/NewCondition",
    "priceValidUntil": "{{ 'now' | date: '%Y' | plus: 1 }}-12-31",
    "seller": {
      "@type": "Organization",
      "name": {{ shop.name | json }}
    }
  }
}
</script>

Three things to note:

AggregateOffer variant

Replace the "offers" block for a price-range product

"offers": {
  "@type": "AggregateOffer",
  "offerCount": {{ product.variants.size }},
  "lowPrice": {{ product.price_min | divided_by: 100.0 | json }},
  "highPrice": {{ product.price_max | divided_by: 100.0 | json }},
  "priceCurrency": {{ cart.currency.iso_code | json }},
  "availability": "https://schema.org/{% if product.available %}InStock{% else %}OutOfStock{% endif %}"
}

AggregateRating & Review

Stars are the highest-CTR enhancement available to a product listing, and the fastest way to earn a manual action if you get them wrong. Two hard rules:

With Shopify's first-party Product Reviews app retired, the metafield namespace depends on your app. Judge.me writes to product.metafields.reviews; adjust to match.

sections/main-product.liquid — inside the Product object

{%- assign rating = product.metafields.reviews.rating.value -%}
{%- assign rating_count = product.metafields.reviews.rating_count.value -%}
{%- if rating_count > 0 -%}
  ,"aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": {{ rating.rating | json }},
    "reviewCount": {{ rating_count | json }},
    "bestRating": {{ rating.scale_max | default: 5 | json }},
    "worstRating": {{ rating.scale_min | default: 1 | json }}
  }
{%- endif -%}
If you use a reviews app

Judge.me, Loox and Okendo all inject their own AggregateRating, usually on their own Product node. Pick one owner: disable the app's rich-snippet setting and use the block above, or leave the app in charge and remove the theme's. Two nodes with different reviewCount values is worse than either alone.

BreadcrumbList

Cheap, low-risk, and it replaces the raw URL in the SERP with a readable trail. On Shopify the useful signal is the collection the product was reached through, exposed as collection when the URL is collection-scoped.

snippets/breadcrumb-schema.liquid — render from product + collection templates

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "Home",
      "item": {{ shop.url | json }}
    }
    {%- if collection -%}
    ,{
      "@type": "ListItem",
      "position": 2,
      "name": {{ collection.title | json }},
      "item": {{ shop.url | append: collection.url | json }}
    }
    {%- endif -%}
    {%- if product -%}
    ,{
      "@type": "ListItem",
      "position": {% if collection %}3{% else %}2{% endif %},
      "name": {{ product.title | json }}
    }
    {%- endif -%}
  ]
}
</script>

The final item deliberately has no item property — it's the current page, and Google's guidance is to omit the URL on the last element. Then render it:

sections/main-product.liquid

{% render 'breadcrumb-schema', product: product, collection: collection %}

Organization

One node, homepage only, describing the business itself. It feeds the knowledge panel and lets Google reconcile your brand across profiles via sameAs. Emitting it on every URL is common and pointless duplication.

layout/theme.liquid — before </head>

{%- if request.page_type == 'index' -%}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "OnlineStore",
  "@id": {{ shop.url | append: '#organization' | json }},
  "name": {{ shop.name | json }},
  "url": {{ shop.url | json }},
  "logo": {
    "@type": "ImageObject",
    "url": {{ settings.logo | image_url: width: 600 | prepend: "https:" | json }}
  },
  "description": {{ shop.description | json }},
  "email": {{ shop.email | json }},
  "sameAs": [
    "https://www.instagram.com/your-handle",
    "https://www.linkedin.com/company/your-company",
    "https://www.youtube.com/@your-channel"
  ]
}
</script>
{%- endif -%}

OnlineStore is a merchant-specific subtype of Organization — use it over the generic type. Populate sameAs only with profiles you control; unverifiable entries do nothing.

3. The Shopify canonical trap

The one non-markup item that earns its place here, because it's a platform-specific duplication problem no amount of correct schema will fix.

Shopify serves the same product at two URL patterns:

/products/merino-crew-neck                        ← canonical
/collections/knitwear/products/merino-crew-neck   ← collection-scoped duplicate

Collection pages link to the second form, so most stores link almost exclusively to the duplicate. Shopify emits a correct <link rel="canonical"> back to /products/… — but only if your theme outputs {{ canonical_url }}. Customised themes drop it surprisingly often, so verify:

Terminal

curl -s https://your-store.com/collections/knitwear/products/merino-crew-neck \
  | grep -E 'rel="canonical"|og:url'

Both should return the bare /products/… URL. If og:url disagrees with the canonical, social signals split across two identities — which is why the §1 snippet uses {{ canonical_url }} rather than request.path.

Two related items worth thirty seconds each:

4. Validating

  1. Rich Results Test — the authoritative check. Paste the live URL, not the code, so it renders as Googlebot does and catches app-injected JSON-LD. It reports which rich result types the page is eligible for. Errors block eligibility; warnings are optional properties.
  2. Schema Markup Validator — validates against the full schema.org vocabulary rather than Google's subset. Use it to catch syntax errors first.
  3. Sharing Debugger / Post Inspector — OG tags, and the only way to bust their caches.
  4. Search Console → Enhancements — the one that tells the truth at scale, across every indexed URL rather than the one you happened to test. It's how you catch the variant that went out of stock and broke its own offer.

Test at minimum: one in-stock product, one sold-out product, one multi-variant product, one collection, and the homepage. Sold-out is where availability logic usually breaks.

Calibration

Valid markup doesn't entitle you to a rich result — Google decides per query and per site, and eligibility can take weeks to surface after re-crawl. What structured data reliably does is remove your markup as the reason you don't have one. Ship it, verify in Search Console, stop refreshing.

5. Notes on this page's own markup

This page implements what it describes — view source for a real <title>, meta description, self-referencing canonical, the full OG and Twitter set, and a JSON-LD @graph. Two deliberate choices:

TechArticle, not HowTo. Google deprecated HowTo rich results in September 2023 — the markup still validates, it just no longer produces a visual enhancement. Same for FAQPage, now restricted to government and health sites. Marking this page up as HowTo would have been valid and pointless. Applies to your store too: don't build HowTo blocks for product-care instructions expecting a SERP feature.

An @graph with @id references rather than separate script blocks. Each entity is declared once and referenced elsewhere by @id, so the Article's publisher and the WebSite's publisher resolve to the same Organization node instead of two unlinked copies. Worth applying on a store once Product, Breadcrumb and Organization are all in play — it's the difference between a graph Google can traverse and three disconnected assertions.

Implementation order

If you're shipping this week: (1) audit for duplicate ld+json blocks and consolidate — this is usually a net removal of code; (2) fix snippets/meta-tags.liquid and force a re-scrape; (3) extend the theme's Product schema with brand, sku and a guarded aggregateRating; (4) add BreadcrumbList; (5) Organization on the homepage; (6) verify canonicals and switch internal product links to | within: null.

Questions on a specific theme, or a store that's emitting schema you can't trace? Get in touch.