Canonical tags: how to check one is working
A canonical tag, <link rel="canonical" href="…">, tells search engines which URL is the main version of a page when several URLs show the same thing. To work, there should be one, in the head, with a full URL, pointing at a page that loads directly (no redirect), isn't noindex and names itself as canonical. It's a strong signal, not an order: Google can still choose a different URL. Check it with the snippet below, then see which canonical Google chose in Search Console.
What a canonical tag does
The same page is often reachable at several addresses: with tracking parameters, with a sort order, with or without a trailing slash. A canonical tells search engines to treat them all as one page and show the main URL:
/widgets/blue?utm_source=email/widgets/blue?sort=price/Widgets/Blue/widgets/blueEvery version, including the main one, carries the same canonical. On the main page it points at itself: Google recommends that self-referencing canonical.
Google treats the canonical as a strong signal, alongside redirects and sitemaps. If the signals disagree, Google may pick its own. It also helps to link to the canonical URL from your own pages, so internal links point the same way.
What the canonical must point at
A canonical only works if the target is a page Google would index:
/widgets/blue-2023/widgets/blue/widgetschainEach step on its own looks reasonable. Together they leave Google three different answers to "which URL is the main one?"
The rules for the tag itself
https://example.com/widgets/blue, not /widgets/blue.<head>. One pushed into the body by a stray element is ignored (see invalid elements in the head).Link header, which is useful for PDFs. If you use both, they must name the same URL.How to check by hand
1Check the canonical and its target 1 minute
Open DevTools (F12), go to the Console and paste:
// Checks this page's canonical, then the page it points to (if it's on this domain)
const clean = u => u.split('#')[0];
const here = clean(location.href);
const res = await fetch(here, { cache: 'no-store', headers: { Accept: 'text/html' } });
const server = new DOMParser().parseFromString(await res.text(), 'text/html');
const tags = doc => [...doc.querySelectorAll('link[rel~="canonical" i]')];
const linkCanonical = h => (h || '').split(/,(?=\s*<)/)
.map(p => p.match(/^\s*<([^>]*)>(.*)$/))
.find(m => m && /;\s*rel\s*=\s*("(?:[^"]*\s)?canonical(?:\s[^"]*)?"|canonical\s*(?:;|$))/i.test(m[2]))?.[1] || '';
// noindex or none for all robots or Googlebot; ignores other bots and values such as max-image-preview:none
const noindex = (metas, header) => {
const hit = text => text.split(/[\s,]+/).some(w => w === 'noindex' || w === 'none');
const meta = metas.some(c => c.toLowerCase().split(',').some(p => !p.includes(':') && hit(p.trim())));
let agent = '';
const head = (header || '').toLowerCase().split(',').some(p => {
let text = p.trim();
const m = text.match(/^([a-z0-9_-]+)\s*:\s*(.*)$/);
if (m && !/^(max-snippet|max-image-preview|max-video-preview|unavailable_after)$/.test(m[1])) { agent = m[1]; text = m[2]; }
else if (m) return false;
return (!agent || agent === 'googlebot') && hit(text);
});
return meta ? 'YES (meta tag)' : head ? 'YES (X-Robots-Tag)' : 'no';
};
const rendered = tags(document);
const header = linkCanonical(res.headers.get('link'));
const headerUrl = header ? clean(new URL(header, here).href) : '';
const canonical = rendered[0] ? clean(rendered[0].href) : headerUrl;
const serverUrl = tags(server)[0] ? clean(new URL(tags(server)[0].getAttribute('href') || '', here).href) : '';
const report = {
'Canonical tags (server / rendered)': tags(server).length + ' / ' + rendered.length,
'Changed by JavaScript': rendered[0] && serverUrl && serverUrl !== canonical ? 'YES: server HTML says ' + serverUrl : 'no',
'Link header canonical': !headerUrl ? '(none)' : rendered[0] && headerUrl !== canonical ? headerUrl + ' (DIFFERS from the tag)' : headerUrl,
'Canonical URL': canonical || '(none)',
'Full URL in the tag': !rendered[0] ? '' : /^https?:\/\//i.test(rendered[0].getAttribute('href') || '') ? 'yes' : 'NO: use a full URL',
'In the head': !rendered.length ? '' : rendered.every(l => l.closest('head')) ? 'yes' : 'NO: one is in the body',
'Points to': !canonical ? '' : canonical === here ? 'this page'
: !canonical.includes('?') && canonical === here.split('?')[0] ? 'this page, without the query string'
: 'a DIFFERENT page',
};
if (canonical && canonical !== here && canonical.startsWith(location.origin + '/')) {
const t = await fetch(canonical, { cache: 'no-store', headers: { Accept: 'text/html' } });
const target = new DOMParser().parseFromString(await t.text(), 'text/html');
const metas = [...target.querySelectorAll('meta[name="robots" i], meta[name="googlebot" i]')].map(m => m.getAttribute('content') || '');
const ownRaw = target.querySelector('link[rel~="canonical" i]')?.getAttribute('href') || linkCanonical(t.headers.get('link'));
const own = ownRaw ? clean(new URL(ownRaw, t.url).href) : '';
report['Target status'] = t.redirected ? 'REDIRECTS to ' + t.url + ' (' + t.status + ')' : t.ok ? t.status : t.status + ': ERROR';
report['Target noindex'] = noindex(metas, t.headers.get('x-robots-tag'));
report["Target's own canonical"] = !own ? '(none)' : own === clean(t.url) ? 'itself' : own + ' (ANOTHER page: a chain)';
}
console.table(report);
It counts the canonical tags in the server HTML and the rendered page, flags a canonical changed by JavaScript, reads any canonical in the HTTP header and says if it differs, checks the tag uses a full URL and sits in the head, and says whether it points at this page. If it points at another page on the same domain, it opens that page and checks its status, noindex and whether its own canonical names itself.
If the canonical only drops the query string, it shows "this page, without the query string", which is usually what you want. A canonical with a different query string, such as ?page=1 on page 2, counts as a different page. Targets on another domain can't be fetched from the Console: open those yourself.
2See which canonical Google chose 2 minutes
In Google Search Console, paste the URL into URL Inspection and open Page indexing. It shows the User-declared canonical (yours) and the Google-selected canonical. Look at the indexed result, not the live test: the live test can't show which canonical Google chose. If they differ, Google isn't following your tag. Look for the conflicting signal: internal links, redirects or the sitemap using a different URL.
3Look for duplicates in the source 1 minute
Press Ctrl+U and search for canonical. There should be one rel="canonical" link, near the top of the head. Two usually means the theme and an SEO plugin both add one.
Seeing a canonical status in Search Console? Read what the alternate and duplicate statuses mean.
On Shopify, WordPress and Next.js
- Shopify: Themes usually build the tag from the
canonical_urlLiquid object intheme.liquid. If a canonical looks wrong, check there and in any SEO app that adds its own. - WordPress: Core adds a canonical on single posts and pages through
rel_canonical(), and SEO plugins usually replace it. If you see two, find what's adding the second. - Next.js: Set
alternates.canonicalin the metadata. A relative URL needsmetadataBase, usually set in the root layout, or the build fails.
How crawlers report this
If a crawl sent you here, these are the labels to look for. Our guide to Screaming Frog and Sitebulb issues covers other common ones.
| Screaming Frog | Sitebulb | What it means |
|---|---|---|
| Canonicals: Missing | No direct match | The page doesn't say which URL is the preferred one. |
| Canonicals: Multiple Conflicting | Multiple, mismatched canonical tags | The page names different preferred URLs, so Google may ignore them. |
| Canonicals: Canonicalised | Canonical points to a different internal URL | The page says another URL is the main one. That's correct for a variant and a problem for a page you want indexed. |
| Canonicals: Non-Indexable Canonical | Canonical points to a noindex URL | The preferred URL can't be indexed, so the signals conflict. |
| No direct match | Canonical points to a redirecting URL | The canonical should name the final URL, not one that redirects. |
| No direct match | Canonical points to another canonicalized URL | The preferred URL names a different preferred URL, forming a chain. |
| No direct match | Mismatched canonical tag in HTML and HTTP header | The HTML and the response header disagree. |
| No direct match | Canonical is malformed or empty | The canonical has no usable URL. |
Doing this on every page?
SEOSnip checks canonicals for you
Open the extension on any page and it reads the canonical in the HTML and in the HTTP header. Press the link check button and it opens the canonical URL named in the HTML. You get:
- A missing, duplicate or invalid canonical, and an HTML canonical that disagrees with the header
- A canonical pointing at a different page. Canonicals that differ from the page only in the query string are skipped
- A target that returns an error, redirects, is noindex or names another canonical
- A ticket with that evidence, and a "Check if fixed" button that re-runs the check
Canonical URL redirects
| Target | Result | Found at |
|---|---|---|
Canonicalhttps://example.com/widgets/blue-2023→ ends at https://example.com/widgets/blue | redirects | head > link:nth-of-type(2) |