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
<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.
// 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:
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, everywhereTrailing 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.
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.
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.