O erro de canonical que esconde as tuas traduções

O agentready.md publica em dez idiomas com prefixo no caminho. Cada página existe em /about, /es/about, /ja/about e mais sete. Durante meses, as nove traduções serviram a mesma linha no <head>:

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

/es/about estava a dizer aos motores de busca: sou um duplicado da página inglesa, indexa essa. /ja/about dizia o mesmo. As outras sete também. Dez idiomas, um indexável.

O que um canonical declara mesmo

rel="canonical" não é uma sugestão sobre qual URL fica mais arrumado. É uma afirmação: esta página e a que eu indico são a mesma, e a indicada é a que merece o índice. Ligações, sinais de posicionamento e a própria entrada no índice vão todos para o destino. Esta página deixa de competir.

É o comportamento certo quando /produto?cor=azul aponta para /produto. É o comportamento errado numa tradução. Uma página em espanhol não é um duplicado da inglesa: é outra página, para outro leitor, e tem de posicionar sozinha nas pesquisas em espanhol.

Porque é que sobreviveu tanto tempo

O nosso router retira o prefixo de idioma antes do encaminhamento, por isso um pedido a /es/about chega ao handler como /about e o idioma viaja à parte. É um desenho sensato, porque as rotas declaram-se uma vez e funcionam em todos os idiomas, mas deixa request.url sem o prefixo, e o parcial do <head> construía o canonical a partir desse caminho nu.

Em inglês o bug produz o resultado certo, porque o inglês não leva prefixo. Todas as páginas que vês enquanto desenvolves no teu idioma estão bem. Só parte nos nove que não lês.

A correção é uma chamada, que devolve o prefixo antes de imprimir o URL:

<%# request.url já perdeu o prefixo /es/ — localizedUrl devolve-o. %>
<% const canonicalHref = localizedUrl(canonicalPath); %>
<link rel="canonical" href="<%= baseUrl %><%= canonicalHref %>">

A regra: cada versão é canonical de si própria

Canonical autorreferente, sempre. /es/about declara /es/about. /ja/about declara /ja/about. Para traduções não há exceção. E se duas versões forem quase iguais, a mesma ficha de produto com três palavras trocadas, não muda nada: idioma não é duplicação.

hreflang: cada versão lista todas as versões

O canonical diz que página indexar. O hreflang diz qual das páginas indexadas mostrar a que leitor. Precisas dos dois, e há duas regras do hreflang que quase toda a gente falha.

Cada versão lista todas as versões, incluindo ela própria. Dez idiomas são dez ligações alternate nas dez páginas, com a autorreferência lá dentro. Faltar essa autorreferência é o defeito mais comum num conjunto por outro lado correto.

As ligações têm de ser recíprocas. Se a página inglesa nomeia a espanhola e a espanhola não retribui, o motor descarta a relação, muitas vezes o conjunto inteiro, porque não a consegue verificar. Qualquer um pode dizer que é a tradução de alguém. Só a outra página confirma.

Gera-as a partir de uma lista em vez de manteres dez:

<% ['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 %>">

Usa o código de idioma sozinho (es) quando uma versão serve todos os falantes. Acrescenta região (es-ES, pt-BR) só se publicares mesmo versões diferentes por mercado; inventar regiões que não tens parte o conjunto para nada.

x-default não é mais uma tradução

O x-default indica a versão a servir a quem lê num idioma que não publicas. Alguém pesquisa em neerlandês e tu não tens neerlandês: leva o que estiver apontado ali.

Não é «o idioma mais importante» nem mais uma entrada na rotação. O nosso fica no URL inglês sem prefixo, que já é o recurso a que todo o site recorre. Apontá-lo para uma página com idioma próprio, /es/about, manda para espanhol todos os leitores para quem não tens tradução, e isso é pior do que o predefinido.

Verifica em uma linha

A falha toda vê-se do terminal. Percorre os teus idiomas e imprime o que cada página declara:

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

A saída correta nomeia em cada linha o seu próprio caminho:

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

Dez linhas com o mesmo URL são o bug deste artigo. Corre isto contra os templates que mexeste e não só contra a página inicial: o canonical costuma ser construído por layout, e um site com três layouts pode estar certo em dois.

As outras formas de partir isto

Parâmetros de tracking dentro do canonical. Se construíres o canonical a partir do URL do pedido tal como vem, quem chegar por ?utm_source=newsletter recebe um canonical a apontar para ?utm_source=newsletter. Cada partilha cria um URL novo que se declara canonical, ou seja um conjunto sem limite de URLs a servir uma só página. Constrói a partir do caminho e devolve só os parâmetros que identificam mesmo outra página, uma listagem filtrada ou uma paginação, numa ordem fixa, para que uma página não consiga escrever o próprio nome de duas maneiras.

Canonical a apontar para um redirecionamento. Se /es/about declara /es/about/ e isso faz um 301 de volta para /es/about, deste ao rastreador um ciclo para resolver e um motivo para desconfiar do sinal. Aponta para o URL que devolve 200.

Dois canonical em contradição. O <link> no HTML e um cabeçalho HTTP Link: <...>; rel="canonical" são ambos válidos, e CDNs, plugins e proxies inversos põem-nos. Quando divergem, o resultado fica indefinido e raramente é o que querias. Vê também os cabeçalhos:

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

Não sair nada é a resposta boa, a não ser que o tenhas posto de propósito.

Analisa o teu site · Gerador de meta tags · Checklist de preparação para IA