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:
| Tag | Controls | Notes for a product page |
|---|---|---|
og:title | Headline of the preview card | Product name. Keep it under ~60 chars; don't append your store name twice. |
og:description | Body text under the headline | ~110–160 chars. Strip HTML from the product body — see the filter chain below. |
og:image | The preview image | The single highest-impact tag. Needs an absolute URL. 1200×630 is the safe target. |
og:url | Canonical identity of the shared object | Must be the canonical product URL, not the collection-scoped one. See §3. |
og:type | Object type | product 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>
{{ 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
- Protocol-relative image URLs.
image_urlreturns//cdn.shopify.com/…. Fine in a browser, broken in half the scrapers, which fetch with no base URL. Thehttps:prefix above isn't decorative. - Using
img_url. Deprecated.image_urlalso requires an explicitwidthorheight. - Unescaped descriptions. A product body containing a straight double quote terminates the
contentattribute and silently corrupts every tag after it.| escapeon every interpolated attribute. - 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.
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
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:
| jsonon every dynamic value — it escapes quotes, newlines and unicode and adds the surrounding quotes, which is why nothing above is hand-quoted. Hand-quoting breaks the first time a title contains an apostrophe or a 6" measurement.- Multi-variant stores: a single
Offerdescribes only the selected variant. Swap inAggregateOfferbelow, or emit an array ofOfferobjects, one per variant with its ownskuand?variant=IDURL. - Markup must match what's rendered. Schema saying
InStockover a sold-out button is a structured-data violation, not a cosmetic mismatch. Drive both from the samev.available.
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:
- Only mark up reviews visible on that page. Ratings in JSON-LD with no corresponding on-page content is the textbook spam pattern.
- Never emit
aggregateRatingwith zero reviews."reviewCount": 0is an invalid-object error and suppresses the whole Product result. Guard it.
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 -%}
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:
- Link products canonically. In a collection grid,
{{ product.url }}emits the collection-scoped form. Use{{ product.url | within: null }}— or just/products/{{ product.handle }}— to consolidate internal PageRank rather than dilute it. - Tag-filtered collection URLs (
/collections/knitwear/blue+wool) are crawlable and near-infinite in combination. Unless a filtered view targets real demand, keep it out of the index viarobots.txt.liquid.
4. Validating
- 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.
- Schema Markup Validator — validates against the full schema.org vocabulary rather than Google's subset. Use it to catch syntax errors first.
- Sharing Debugger / Post Inspector — OG tags, and the only way to bust their caches.
- 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.
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.
