Canonical tags: how to check one is working

Short answer

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:

Every 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:

200, indexable Correct. Loads directly, no noindex, and its own canonical names itself.
301 · 302 Redirects. The canonical names an old address. Point it at the final URL.
404 · 410 · 5xx An error page. The "main version" doesn't exist.
noindex Contradiction. "Index that page instead" pointing at a page that says "don't index me".
canonical → elsewhere A chain. The target names yet another page as canonical. Point straight at the end of the chain.
a different page Check it's meant. Fine for variants, but a product page pointing at its category hides the product.
This page names/widgets/blue-2023
301
which redirects to/widgets/blue
canonical
which names/widgetschain

Each 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

One canonical Two canonical tags, often one from the theme and one from a plugin, can contradict each other, and search engines may ignore both.
A full URL Google recommends absolute URLs: https://example.com/widgets/blue, not /widgets/blue.
In the head Google only accepts the canonical link in the <head>. One pushed into the body by a stray element is ignored (see invalid elements in the head).
One method The canonical can also be sent as an HTTP Link header, which is useful for PDFs. If you use both, they must name the same URL.
In the server HTML Google warns against changing the canonical with JavaScript. See JavaScript SEO.
Not robots.txt or noindex Neither one picks a canonical. Blocking a duplicate in robots.txt stops Google seeing that it's a duplicate at all.

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_url Liquid object in theme.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.canonical in the metadata. A relative URL needs metadataBase, 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 FrogSitebulbWhat it means
Canonicals: MissingNo direct matchThe page doesn't say which URL is the preferred one.
Canonicals: Multiple ConflictingMultiple, mismatched canonical tagsThe page names different preferred URLs, so Google may ignore them.
Canonicals: CanonicalisedCanonical points to a different internal URLThe 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 CanonicalCanonical points to a noindex URLThe preferred URL can't be indexed, so the signals conflict.
No direct matchCanonical points to a redirecting URLThe canonical should name the final URL, not one that redirects.
No direct matchCanonical points to another canonicalized URLThe preferred URL names a different preferred URL, forming a chain.
No direct matchMismatched canonical tag in HTML and HTTP headerThe HTML and the response header disagree.
No direct matchCanonical is malformed or emptyThe 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