The canonical mistake that hides your translations

agentready.md publishes in ten languages behind a path prefix. Every page exists at /about, /es/about, /ja/about and seven more. For months, all nine translations shipped the same line in their <head>:

<link rel="canonical" href="https://agentready.md/about">

/es/about was telling search engines: I am a duplicate of the English page, index that one instead. So was /ja/about. So were the other seven. Ten languages, one of them indexable.

What a canonical actually says

rel="canonical" is not a hint about which URL looks tidiest. It is a claim that this page and the one it names are the same page, and that the named one is the copy worth indexing. Links, ranking signals and the index entry itself all get credited to the target. This page stops competing.

That is the right behaviour for /product?color=blue pointing at /product. It is the wrong behaviour for a translation. A Spanish page is not a duplicate of an English one. It is a different page for a different reader, and it should rank on its own for Spanish queries.

Why it survived so long

Our router strips the language prefix before dispatch, so a request for /es/about reaches the route handler as /about and the language travels separately. That is a sane design — routes are declared once and work in every language — but it means request.url no longer contains the prefix, and the <head> partial was building the canonical straight from that bare path.

In English the bug produces the correct output, because English has no prefix. Every page you look at while developing in your own language is right. It only goes wrong in the nine you don't read.

The fix is one call, putting the prefix back before the URL is printed:

<%# request.url has already lost the /es/ prefix — localizedUrl puts it back. %>
<% const canonicalHref = localizedUrl(canonicalPath); %>
<link rel="canonical" href="<%= baseUrl %><%= canonicalHref %>">

The rule: every version is canonical to itself

Self-referencing canonical, always. /es/about declares /es/about. /ja/about declares /ja/about. No exceptions for translations, ever. If two language versions are near-identical — the same product page with three words changed — that changes nothing. Language is not duplication.

hreflang: every version lists every version

The canonical says which page to index. hreflang says which of the indexed pages to show which reader. You need both, and hreflang has two rules people get wrong.

Every version lists every version, including itself. A ten-language set means ten alternate links on all ten pages, self-reference included. Missing the self-reference is the most common defect in an otherwise correct set.

The links must be reciprocal. If the English page names the Spanish one and the Spanish one does not name the English one back, search engines discard the relationship — often the whole set — because it is unverifiable. Anyone can claim to be somebody's translation. Only the other page can confirm it.

Generate them from one list rather than maintaining ten:

<% ['en','es','fr','de','pt','it','ja','zh','ko','ru'].forEach(l => { %>
<link rel="alternate" hreflang="<%= l %>" href="<%= baseUrl %><%= localizedUrl(canonicalPath, l) %>">
<% }) %>
<link rel="alternate" hreflang="x-default" href="<%= baseUrl %><%= canonicalPath %>">

Use a bare language code (es) when one version serves all speakers of that language. Add a region (es-ES, pt-BR) only when you genuinely publish separate versions per market — inventing regions you do not have splits the set for nothing.

x-default is not a translation

x-default names the version to serve a reader whose language you do not publish. Someone searching in Dutch, when you have no Dutch, gets whatever x-default points at.

It is not "the most important language" and it is not another entry in the rotation. Ours stays on the unprefixed English URL, which is the fallback the whole site already falls back to. Pointing it at a language-specific page — /es/about — sends every reader you have no translation for to a page in Spanish, which is worse than the default.

Check it in one line

The whole failure is visible from the command line. Loop over your languages and print what each page claims:

for p in "" es/ fr/ de/ pt/ it/ ja/ zh/ ko/ ru/; do
  printf '%-4s ' "${p:-en}"
  curl -s "https://example.com/${p}about" | grep -o '<link rel="canonical"[^>]*>'
done

Correct output has each line naming its own path:

en   <link rel="canonical" href="https://example.com/about">
es   <link rel="canonical" href="https://example.com/es/about">
fr   <link rel="canonical" href="https://example.com/fr/about">

Ten lines with the same URL is the bug in this post. Run it against the templates you changed, not just the home page — canonicals are usually built per layout, and a site with three layouts can be right in two of them.

The other ways this breaks

Tracking parameters in the canonical. If you build the canonical from the raw request URL, a visitor arriving on ?utm_source=newsletter gets a canonical pointing at ?utm_source=newsletter. Every share creates a new URL that declares itself canonical, which is an unbounded set of URLs all serving one page. Build the canonical from the pathname and add back only the parameters that genuinely identify a different page — a filtered listing, a paginated archive — in a fixed order, so one page cannot produce two spellings of its own name.

Canonical pointing at a redirect. If /es/about declares /es/about/ and that 301s back to /es/about, you have handed the crawler a loop to resolve and a reason to distrust the signal. Point at the URL that returns 200.

Two canonicals disagreeing. The <link> in the HTML and an HTTP Link: <...>; rel="canonical" header are both valid, and CDNs, plugins and reverse proxies all add them. When they disagree, the outcome is undefined and usually not the one you wanted. Check the headers too:

curl -sI https://example.com/es/about | grep -i "^link:"

Nothing there is the answer you want, unless you put it there deliberately.

Check your site · Meta tags generator · AI-readiness checklist