Skip to content
5 min read

The canonical tag bug that quietly de-indexes pages

A canonical pointing at the wrong URL tells Google to drop the page. The five ways it goes wrong, and how to audit every canonical on a site in an afternoon.

On this page
  1. The claim the tag makes
  2. The five ways it goes wrong
  3. The deeper problem is conflicting signals
  4. Auditing the whole site
  5. Getting it right once
  6. Why this one is different

Of all the ways to lose organic traffic, this is the quietest. Nothing 404s. Nothing is slow. The pages render perfectly for every human who visits.

They simply stop appearing in search results, because of one line in the <head> saying this is not the page you want.

The claim the tag makes

html
<link rel="canonical" href="https://www.example.com/blog/some-post" />

It says: when several URLs serve this content, index and rank that one. Google treats it as a strong hint and consolidates signals onto the named URL.

Which makes the failure asymmetric, and that asymmetry is the whole point of this post. A missing canonical is a mild problem, where duplicates may compete and signals may split. A canonical pointing at the wrong URL is an instruction to drop this page, and it gets followed.

The five ways it goes wrong

The same canonical on every page. The most destructive version, and it always comes from a template.

tsx
// Every page in the app now claims to be the home page
export const metadata = {
  alternates: { canonical: "https://www.example.com" },
};

Hard-coded in a layout, or built from a variable that turns out to be empty on most routes. Every page tells Google it is a duplicate of the home page and the site collapses to one indexed URL. Sites have lost most of their organic traffic to exactly this and spent the following weeks auditing their content quality.

Host and protocol drift. The page serves at https://www.example.com/x and the canonical says http://example.com/x. Different host, different protocol, both wrong. Build canonicals from one configured base and never from the incoming request:

src/lib/seo.tsts
export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.example.com";

export const canonicalFor = (path: string) =>
  new URL(path, SITE_URL).toString();   // one base, everywhere

Trailing slash inconsistency. /blog/post and /blog/post/ are different URLs. If the framework serves one, the canonical names the other and the sitemap lists a third, you have turned a simple thing into an ambiguous one. Pick a convention, enforce it with a redirect, generate everything from the same helper.

Parameters leaking in. A canonical generated from the full request URL carries ?utm_source=newsletter straight into the tag, which creates a distinct canonical for every campaign you have ever run. Canonicals come from the route, never from whatever the browser happened to request.

Pointing somewhere unindexable. A canonical naming a URL that 404s, redirects, or carries noindex is a contradiction, and Google resolves it by ignoring the canonical and deciding for itself. That is precisely the outcome the tag exists to prevent.

The deeper problem is conflicting signals

The canonical is one of four things telling Google which URL matters.

Signal Says
rel="canonical" This is the preferred URL
Sitemap entry This URL exists and should be crawled
Internal links This is the URL the site itself uses
Redirects This URL has moved

When they disagree, Google picks. A sitemap listing the non-slash version, internal links using the slash version and a canonical naming the www version is three votes and no majority, and the outcome is whatever the crawler infers.

So the audit question is not whether canonicals exist. It is whether all four signals name the same string. Generating all four from one helper is what makes that true by construction instead of true by vigilance, and vigilance has a failure rate.

Auditing the whole site

Crawl, extract, compare. The report is the mismatches.

python
import requests, csv
from bs4 import BeautifulSoup

rows = []
for url in sitemap_urls:                       # from your own sitemap.xml
    r = requests.get(url, timeout=20, allow_redirects=False)
    soup = BeautifulSoup(r.text, "html.parser")
    tag = soup.find("link", rel="canonical")
    canonical = tag["href"] if tag else None
    robots = soup.find("meta", attrs={"name": "robots"})
    rows.append({
        "url": url,
        "status": r.status_code,
        "canonical": canonical,
        "self": canonical == url,
        "robots": robots["content"] if robots else "",
    })

with open("canonicals.csv", "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=rows[0].keys()); w.writeheader(); w.writerows(rows)

Read the output in this order. Rows with no canonical at all. Rows where self is false. Then any canonical appearing on more than one URL, which is the templated bug and which shows up as a count rather than something you have to read carefully to notice.

Writing the crawler yourself takes an hour and teaches you more than a vendor report would, which is roughly the argument I made for building a Googlebot-style crawler. Search Console's Pages report shows the same finding from Google's side under "Alternate page with proper canonical tag", and a large number there that you did not intend is the same discovery arriving several weeks later.

Getting it right once

Generate the canonical from the route, in one place, and let metadata, sitemap and feed all call it.

app/blog/[slug]/page.tsxts
export async function generateMetadata({ params }) {
  const { slug } = await params;
  return {
    alternates: { canonical: canonicalFor(`/blog/${slug}`) },
  };
}

One function, one base URL, one convention about slashes. After that the tag stops being something anyone has to remember, which is the only reliable state for a failure this quiet. It belongs on the same list as the rest of technical SEO for a Next.js app.

Why this one is different

The canonical tag is the only piece of on-page SEO where a mistake is more damaging than an omission. A page with no canonical competes with its duplicates. A page with a wrong canonical asks to be removed, and is.

Generate it from the route through one helper, make the sitemap and the internal links use that same helper, and crawl your own site occasionally to confirm every page still names itself.

An afternoon of work. The alternative is finding out from a traffic graph, about six weeks late.

Frequently asked

What does a canonical tag do?

It tells search engines which URL is the preferred version of a page when several URLs serve similar content. Google treats it as a strong hint rather than a directive, and consolidates ranking signals onto the URL the canonical names. Point it at the wrong URL and the page you wanted indexed can be dropped in favour of the one you named.

Should every page have a self-referencing canonical?

Yes, as a default. A canonical that names the page's own clean URL removes ambiguity created by tracking parameters, trailing slashes and protocol or subdomain variants. The exceptions are deliberate: paginated series, syndicated copies and filtered variants that should consolidate onto a parent.

Why has Google ignored my canonical tag?

Usually because other signals contradict it. If your sitemap lists one URL, your internal links point at another and the canonical names a third, Google picks for itself. It also ignores canonicals pointing at pages that are noindexed, redirected, 404, or substantially different in content.

How do I check canonicals across a whole site?

Crawl the site, record each page's URL and the canonical in its head, and list every row where they differ. Most mismatches are legitimate — paginated pages, parameter variants — so the report you want is the mismatches you cannot explain, grouped by pattern rather than listed one by one.

Found this useful?Share on XShare on LinkedIn

Related reading