Search Console had been quiet for weeks. Then a row appeared in the Pages report that I had to read three times:
User-declared canonical: https://codxeditor.com/ Google-selected canonical: https://www.codxeditor.com/
Every page on the site declares a canonical URL. Every one of them points
at the bare domain. Google had read them, considered them, and gone with
www anyway.
A canonical tag is a vote, not an instruction
This is the part that catches people out, and it caught me out. The
<link rel="canonical"> tag is documented as a
signal. Google weighs it against everything else it knows and then
decides. If the other signals disagree loudly enough, your tag loses.
So what were the other signals? Both hostnames existed. Both answered on
HTTPS. Both returned 200 OK. Both served byte-identical HTML.
Nothing anywhere in the stack expressed a preference between them —
except one small tag in the head, which the server itself then contradicted
by happily serving the page on the address it claimed was not canonical.
A canonical tag that your server does not enforce is an unbacked claim. You are telling a crawler that one address is the real one while demonstrating, on every request, that both work equally well. When the words and the behaviour disagree, the behaviour wins.
Why it actually matters
Duplicate content across two hostnames is not a penalty. Nobody is punishing you. The damage is quieter than that: inbound links, crawl budget and ranking signals get divided between two addresses that are treated as separate pages. Neither one accumulates what a single address would have.
There is a second-order problem too. Google was indexing the
www version, and every internal link on the site pointed at
the bare domain. So the indexed address and the linked address were
different, and every internal link was effectively pointing at a page
Google considered a duplicate of the one it had indexed.
The fix is a redirect, and its position matters
The answer is not a better canonical tag. It is making the wrong hostname
stop returning content at all. A 301 to the bare domain
removes the ambiguity completely: there is no longer a choice for Google
to make, because only one address ever returns a page.
In Express that is a few lines. What matters more than the lines is where they sit — they have to run before anything that can answer a request:
const CANONICAL_HOST =
String(process.env.CANONICAL_HOST || "codxeditor.com").trim().toLowerCase();
app.use((req, res, next) => {
if (req.method !== "GET" && req.method !== "HEAD") return next();
const host = String(req.headers.host || "").toLowerCase();
if (!host || !CANONICAL_HOST) return next();
if (host !== `www.${CANONICAL_HOST}`) return next();
const protocol = String(
req.headers["x-forwarded-proto"] || req.protocol || "https"
).split(",")[0].trim();
return res.redirect(301, `${protocol}://${CANONICAL_HOST}${req.originalUrl}`);
});
Four details in there are worth pulling out, because each one is a bug I would otherwise have shipped.
It only redirects GET and HEAD
A 301 on a POST is a trap. Browsers are permitted
to convert the follow-up request to a GET and drop the body, so
a form submitted to the www address would arrive at the bare
domain with nothing in it. Leaving non-idempotent methods alone means a
mis-addressed API call fails loudly instead of silently losing data.
It reads x-forwarded-proto, not req.protocol
Behind a reverse proxy, TLS terminates at the proxy. Your Node process sees
a plain HTTP connection and req.protocol reports
"http". Trust that and you build an http://
redirect target, so every visitor gets bounced to insecure HTTP and then
bounced again by HSTS. Two redirects where there should be one, and a
crawler that starts wondering about your redirect chains.
It splits on a comma
Forwarded headers accumulate. Pass through two proxies and
x-forwarded-proto can arrive as https,http. Taking
the whole string gives you a protocol of "https,http" and a
redirect to a URL that does not parse. The first value is the one the
original client used.
It does nothing on any other hostname
The check is host !== "www." + CANONICAL_HOST, not "is this the
canonical host". That distinction saves you on
localhost:3000, on a preview deployment, on a staging
subdomain. A stricter check would redirect your local development server to
production, which is the kind of thing you only discover after ten confused
minutes.
Then you wait
The redirect went live and nothing happened. Search Console still showed the
www address as the selected canonical, and kept showing it for
days.
That is normal, and it is worth saying plainly because the silence is
unnerving. Google has to re-crawl the www address, see the
301, and then decide the redirect is settled rather than
temporary. That decision is not instant — a 301 is a
claim about permanence, and permanence takes time to demonstrate. A week or
two is ordinary.
You can speed up the crawl but not the conclusion. Resubmit the sitemap. Use URL Inspection on the bare domain and request indexing. Then leave it alone. Re-requesting indexing every day does not help and gives you something to obsess over instead of building.
How to check it yourself
One command tells you whether the redirect is doing its job:
curl -sI https://www.codxeditor.com/ | head -n 3
You want HTTP/2 301 and a location header pointing
at the bare domain. A 200 means the redirect is not running, or
is running too late in the middleware stack and something answered first.
The second test is the one people forget: check a deep path, not just the homepage.
curl -sI https://www.codxeditor.com/blog | head -n 3
Preserving the path is why the code uses req.originalUrl rather
than building a target from scratch. Redirecting every www URL
to the homepage would technically remove the duplicate, and would also throw
away every inbound link to every interior page.
What to take from this
- A canonical tag is a request; a redirect is a fact. If your server answers on both hostnames, you have not chosen one, whatever your HTML says.
- Redirect before you route. A canonical redirect placed after other middleware will be beaten to the response by whichever handler matches first.
- Trust the proxy headers, and parse them properly.
req.protocollies behind a load balancer, and forwarded headers can hold more than one value. - Keep the path.
req.originalUrl, not"/". - Expect the fix to look like it failed. Search Console lags by a week or more, and that lag is not evidence that you got it wrong.