Shopify canonical URL: what Shopify writes for every product, variant and collection, and when it is wrong
Shopify strips the collection path, variant and tracking from every canonical. What it keeps, what Google reads alongside it, and the four cases the tag cannot fix.
11 min read
Contents — 7 sections
- Shopify writes the canonical for every page, and for a product it is always the flat URL
- Google treats the tag as a strong hint, reads three other signals with it, and one of them is yours
- Collection-scoped product URLs come from one filter, and neither current theme uses it
- A variant URL is the same page, and Google asked for exactly the canonical Shopify writes
- Pagination, sorting, filters and tags each get a different answer, and the tag one is the trap
- Check one page in a minute and the whole store in a script
- Remove the second tag first, then the scoped links, and it takes an hour
Shopify writes the canonical for every page, and for a product it is always the flat URL
Every Shopify theme carries one line that decides which URL a search engine treats as the page. Shopify's SEO metadata guide shows it, Dawn has it on line 8 of layout/theme.liquid, and Horizon has the same line in snippets/meta-tags.liquid:
<link rel="canonical" href="{{ canonical_url }}">
canonical_url is a global Liquid object with no arguments, no admin field and no theme setting. Its documentation is one sentence, so what it does is best read from what it returns. On Shopify's own Dawn demo store, on 17 September 2026, each URL on the left produced the canonical on the right, always absolute with scheme and host, which is the form Google asks for:
| Requested | Canonical Shopify wrote |
|---|---|
/products/helix-denim | /products/helix-denim |
/collections/bags/products/helix-denim | /products/helix-denim |
/collections/boots/products/helix-denim, a collection the product is not in | /products/helix-denim, and the page is served |
/products/helix-denim?variant=39577013256281 | /products/helix-denim |
/products/helix-denim?utm_source=x | /products/helix-denim |
/products/Helix-Denim and /products/helix-denim/ | /products/helix-denim |
/collections/bags?sort_by=price-ascending | /collections/bags |
/collections/bags?filter.v.price.gte=10 | /collections/bags |
/collections/bags?page=1 | /collections/bags |
/collections/bags?page=2 | /collections/bags?page=2 |
/collections/bags/handbag, a tag-filtered collection | /collections/bags/handbag |
/en-fr/products/helix-denim, a market subfolder | /en-fr/products/helix-denim |
Three rules cover the table. A product's canonical is the flat /products/ URL whatever path, case, trailing slash or query string it was reached by. A collection's canonical drops sorting and filtering but keeps the page number and the tag. A localised subfolder is canonical to itself, and the relationship between markets is carried by hreflang instead. Both themes write og:url from the same object, so the share URL and the canonical never disagree.
None of this is the theme's decision. Dawn and Horizon print the object; the platform computes it. Any theme that still contains the line has the same canonicals as the demo store, and a store whose canonicals differ has had the line edited or has an app writing a second one.
Google treats the tag as a strong hint, reads three other signals with it, and one of them is yours
Google's canonicalisation overview is explicit that the tag is a preference, not an instruction: Google clusters pages it believes are duplicates, picks the one it judges most complete, and may choose differently from you. The page on specifying a canonical ranks the signals it reads. Redirects are the strongest, rel="canonical" is next, and inclusion in a sitemap is a weak signal that stacks with the other two.
Shopify supplies all three without being asked. A request over http:// is answered with a 301 to https://, and once a primary domain is set the .myshopify.com address 301s to it. The product sitemap lists one URL per product, the flat one: on the demo store, not one entry carries ?variant= or a collection path. And the tag is on every page. Three agreeing signals is the stacked case Google describes, which is why an untouched theme rarely has a canonical problem on a product page.
The fourth signal is the one the platform cannot write for you. Google's best-practice list says to link internally to the canonical URL rather than to a duplicate, because consistent linking is how it learns your preference, and the same advice appears in its URL structure guide for ecommerce: use the same URL in internal links, in the sitemap and in the canonical tag. Which URL the theme puts in every product card, every breadcrumb and every menu is a theme decision, and it is where the cases below come from.
The same page lists what not to do, and two items describe things we still find on stores. Do not use robots.txt for canonicalisation, because a disallowed URL can be indexed without its content. And do not declare one canonical in the sitemap and another in the tag, which is what happens when an app rewrites the tag and Shopify's sitemap keeps listing the flat URL.
Collection-scoped product URLs come from one filter, and neither current theme uses it
A Shopify product has one address, /products/handle. The second address, /collections/bags/products/handle, exists because of the within filter, which takes a product URL and a collection and returns the scoped form. Its purpose is to make the collection object available on the product template. Its documentation carries a caution in Shopify's own words: a standard product page and a product page in the context of a collection have the same content on separate URLs, so consider the SEO implications.
Dawn considers them by not using the filter. snippets/card-product.liquid links every card to card_product.url, and Horizon's snippets/product-card.liquid links to product.url. Neither produces a scoped URL anywhere. The filter survives in older and third-party themes, usually for previous-and-next links inside a collection, and in custom breadcrumbs.
Two things about the scoped URL matter before deciding whether to keep it. First, it is served for any collection at all: the demo store returned the product page, with a 200 and the flat canonical, for a collection the product does not belong to. A product can have as many duplicate addresses as the store has collections, which is the case Google's ecommerce guide describes when it asks you to minimise alternative URLs that return the same content. Second, on the scoped page product.url is still the flat URL. Dawn's product section reads it into data-url, and the JSON-LD written by the structured_data filter puts the flat URL in url and @id, so the structured data and the canonical agree even when the address bar does not.
What is wrong, then, is not the tag. It is the link graph. A theme that scopes every card link sends every internal link to a URL whose canonical points elsewhere, and Google's stated preference is to see the canonical linked. On most stores Google follows the tag anyway and Search Console files the scoped URLs as alternate pages with a proper canonical tag. On some it does not, and the status becomes the one where Google chose a different canonical than the user. The fix is a search of the theme for within: and a replacement with the bare product.url. Where the collection context was doing work, in a breadcrumb, Shopify breadcrumb schema covers how to name the collection without scoping the URL.
A variant URL is the same page, and Google asked for exactly the canonical Shopify writes
variant.url has one structure, /products/handle?variant=id. The product page reads the parameter into product.selected_variant and preselects that option. Dawn goes further: sections/main-product.liquid sets data-update-url="true" on the product element, and assets/product-info.js calls history.replaceState with ?variant= on every selection. So the address bar of a Dawn product page carries a variant parameter within one click of arriving, and the URL a visitor copies and links to is a variant URL. The canonical does not move: the script never touches the link element, which is what Google's canonical page asks for when JavaScript changes a page.
Google's ecommerce guide is specific about variants. Each variant should be identifiable by its own URL, as a path segment or as a query parameter, and if the parameter is optional, the URL with the parameter omitted should be the canonical. That is the Shopify pattern to the letter. The product variant structured data documentation describes the same shape for the markup, a ProductGroup at the bare URL with an Offer per variant at the parameterised one, and the structured_data filter emits exactly that: the group's url is /products/handle, each offer's url carries ?variant=.
So the variant canonical is right, and the case where it is wrong is a catalogue problem wearing the tag's clothes. One canonical means one page in the index: one title, one first image, one description. When the colour is what people search for, a product called "Linen shirt" with navy and white as variants has no page for "navy linen shirt", because the navy page is the white page with a parameter the canonical tells Google to fold away. The fix is separate products with their own handles, titles and images, which is what Shopify's combined listings formalise for Plus stores: child products that each have their own URL handle and can be merchandised separately, shown together on one page through a parent. Off Plus, the same decision is made by hand: one product per colour and a link between them. Either way each canonical is its own flat URL, and the variant parameter goes back to carrying the size.
One more variant behaviour explains a number in Search Console. The product object's documentation says that in search results and filtered collections product.url carries the variant parameter of the most relevant variant, and variant.url says the same URLs also carry the discovery tracking parameters _pos, _sid, _ss and _fid. Horizon's card links to product.url on purpose, and its source says why: so that variant relevance and tracking are kept. On Shopify's Horizon demo store, every card on the front-page collection linked with ?variant=. That is a theme choosing non-canonical internal links in exchange for a better preselected variant, and Shopify's canonical corrects it. It is not a change we make, but it explains why a Horizon store's alternate-page count in Search Console is roughly its product count.
Pagination, sorting, filters and tags each get a different answer, and the tag one is the trap
Page two of a collection is canonical to itself, ?page=2 included, and page one is canonical to the bare collection URL. Both are what Google's pagination guidance asks for: give each page a unique URL, and do not use the first page as the canonical of the sequence. An app or a theme edit that points every page at page one is wrong by that page's own words, and it is a common edit because it sounds tidy.
Sorting and the Search & Discovery filters are stripped. ?sort_by= and every filter. parameter canonicalise back to the collection root, and the default robots.txt disallows sort_by and any URL carrying two filter parameters, so those versions are neither indexed as themselves nor crawled at length. Single filters are crawlable and canonicalised, which is the right pair.
Tags are the exception, and they are where the duplicates on most Shopify stores actually are. A tag-filtered collection, /collections/bags/handbag, is canonical to itself. The default robots.txt blocks only the combinations, the URLs with a + between two tags; a single tag is crawlable, indexable, and self-referencing. Its content is the collection minus the products without that tag, and its title is the collection's with – tagged "handbag" appended, which Shopify title tag length covers. A theme with a tag-based filter sidebar therefore creates one indexable near-duplicate per tag per collection, each declaring itself canonical, each linked from every collection page. Fifteen tags across twenty collections is three hundred thin pages that Google is told to keep. The fix is not to edit the canonical: move the filtering to Search & Discovery, whose parameters are stripped, and let the tag URLs fall out of the link graph. A tag page that is genuinely a landing page with its own demand can stay, with its own description and a place in the navigation.
A market subfolder is canonical to itself and hreflang carries the rest. The demo store's /en-fr/products/helix-denim declared itself canonical and listed the /products/ URL as the en and x-default alternates, which is the pairing Google's overview asks for on regional variants. Shopify hreflang covers what Shopify writes there and the mistakes that are never in the tags.
Check one page in a minute and the whole store in a script
For one page, open it, open the console, and paste:
const tags = [...document.querySelectorAll('link[rel="canonical"]')];
console.log(tags.length, 'canonical tag(s)');
tags.forEach((t) => console.log(t.href));
const og = document.querySelector('meta[property="og:url"]')?.content;
console.log('og:url', og, og === tags[0]?.href ? 'matches' : 'DIFFERS');
const ld = [...document.querySelectorAll('script[type="application/ld+json"]')]
.map((s) => { try { return JSON.parse(s.textContent); } catch { return null; } })
.find((d) => d && ['Product', 'ProductGroup'].includes(d['@type']));
console.log('structured data url', ld?.url, ld?.url === tags[0]?.href ? 'matches' : 'DIFFERS');
console.log('address bar', location.href);
The count on the first line is the finding that matters most: two canonical tags means an app or a theme edit added one, and the page now declares two preferences. The next two lines should both read matches; a DIFFERS on og:url means the meta snippet was edited, and one on the structured data means a second product node from an app, which Shopify product schema covers. The last line is for a Dawn store after selecting a variant: the address bar has the parameter and the canonical above it does not.
For the whole store, Shopify publishes every product URL in the sitemap. This reads the product sitemaps and, for each product, requests the flat URL, the same URL through /collections/all/, and the first variant's URL, then reports any page where the canonicals disagree or where there is more than one:
import re, sys, time, urllib.request
from xml.etree import ElementTree as ET
STORE = sys.argv[1].rstrip('/')
HEADERS = {'User-Agent': 'canonical-check/1.0'}
def get(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=20) as r:
return r.read().decode('utf-8', 'replace')
def locs(xml):
root = ET.fromstring(xml)
out = []
for entry in list(root.iterfind('.//{*}sitemap')) + list(root.iterfind('.//{*}url')):
loc = entry.find('{*}loc')
if loc is not None and loc.text:
out.append(loc.text.strip())
return out
def canonicals(src):
return re.findall(r'<link[^>]*rel="canonical"[^>]*href="([^"]*)"', src)
products = []
for child in locs(get(STORE + '/sitemap.xml')):
if 'sitemap_products' in child:
products += [u for u in locs(get(child)) if '/products/' in u]
print('flat,scoped,variant,tags,url')
for url in products:
handle = url.rsplit('/products/', 1)[1]
forms = [url, STORE + '/collections/all/products/' + handle]
m = re.search(r'"variants":\[\{"id":(\d+)', get(url + '.js'))
if m:
forms.append(url + '?variant=' + m.group(1))
seen = [canonicals(get(f)) for f in forms]
first = seen[0][0] if seen[0] else ''
ok = all(c and c[0] == first for c in seen)
if not ok or any(len(c) != 1 for c in seen):
print(first, seen[1][0] if seen[1] else '', seen[2][0] if len(seen) > 2 and seen[2] else '',
max(len(c) for c in seen), url, sep=',')
time.sleep(0.5)
Run it with the store's address as its one argument. An untouched theme prints the header and nothing else. Every row it does print is a page where one of the three forms disagrees with the others, or which carries more than one tag, and the fourth column says which. The .js endpoint it reads is the one Shopify serves for every product, so the variant ID is real, not guessed.
Then read what Google decided. In Search Console, Pages, open two statuses: alternate page with a proper canonical tag, which is where every scoped and variant URL should be, and Google chose a different canonical than the user, which should be empty. For any URL in the second, URL inspection shows the user-declared and the Google-selected canonical side by side, and Google's troubleshooting page gives the timing after a fix: pages can stay in a duplicate cluster for up to two weeks, and re-indexing can be requested for the ones that matter.
Remove the second tag first, then the scoped links, and it takes an hour
First, the second canonical, if the console count showed one. It comes from an app embed or a theme edit, and layout/theme.liquid and the meta snippet are where to look before the apps. Ten minutes, and it is the only item here where Google is being given two answers.
Second, within: in the theme. Search every file for it and replace the scoped URL with product.url. Twenty minutes, and the link graph agrees with the tag from the next crawl.
Third, tag links in the collection filters. If the sidebar builds /collections/x/tag links, move the filtering to Search & Discovery. An hour, most of it deciding which tags were doing real work as landing pages.
Fourth, the variants that deserve pages. This is a catalogue decision made product by product, and the canonical is not the lever; separate products are. It is the slowest item and the only one that creates new pages rather than removing duplicate ones.
The canonical is one line of the head, and the store audit checklist reads it before anything else on the product page, because every other signal consolidates into whichever URL it names. If you would rather have all of it read, ranked against everything else we find, and written up, that is the Store Teardown: €750, five days, and the document is yours whatever you decide.