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.protocol lies 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.