Next.js multilingue com next-intl: a configuração completa e as armadilhas

Este artigo faz parte da série "Redesign de site com IA". Se não leu o primeiro episódio, comece por aí.
Atualizado a 10 de agosto de 2026: adicionada a secção sobre o bug de crawl descoberto cinco meses após o lançamento.
O problema
O meu site estava 100% em francês. Sem seletor de língua, sem estrutura multilingue, nada. No entanto, uma boa parte da minha rede profissional é anglófona, e as minhas origens brasileiras empurravam-me há muito tempo para adicionar o português.
A internacionalização de um site Next.js App Router é a obra que toda a gente adia. E com razão, toca em absolutamente tudo. As rotas, os layouts, os metadados, os componentes, o conteúdo. Cada ficheiro é impactado.
A base: uma única fonte de verdade para o routing
Tudo parte de um ficheiro. O i18n/routing.ts declara as línguas, a língua por omissão, e sobretudo a tabela dos caminhos traduzidos.
// i18n/routing.ts
import { defineRouting } from 'next-intl/routing'
export const routing = defineRouting({
locales: ['fr', 'en', 'pt'],
defaultLocale: 'fr',
localePrefix: 'always',
pathnames: {
'/': '/',
'/sprint': '/sprint',
'/saas-sur-mesure-suisse': {
fr: '/saas-sur-mesure-suisse',
en: '/custom-saas-switzerland',
pt: '/saas-sob-medida-suica',
},
'/blog/[year]/[id]': '/blog/[year]/[id]',
'/tags/[tag]': '/tags/[tag]',
},
})
É a parte que a maioria dos tutoriais salta. Um caminho pode ser idêntico nas três línguas (/sprint) ou traduzido (/saas-sur-mesure-suisse passa a /custom-saas-switzerland em inglês). Traduzir os URLs tem um interesse SEO real, uma página inglesa que vive num URL francês envia um sinal contraditório ao Google.
O localePrefix: 'always' é a outra decisão estruturante. Cada URL carrega a sua língua, incluindo o francês. Sem raiz ambígua, sem deteção automática a decidir em vez do visitante. Voltaremos a isto, porque foi justamente a deteção automática que me criou problemas mais tarde.
Depois, o createNavigation gera as ferramentas que respeitam esta tabela:
// i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)
A partir daí, um <Link href="/saas-sur-mesure-suisse"> escrito uma vez dá o URL certo nas três línguas, automaticamente.
A migração de todas as rotas
É a parte mais delicada. Cada página do site teve de ser movida para uma pasta app/[locale]/. O app/page.tsx passou a app/[locale]/page.tsx, o app/blog/page.tsx passou a app/[locale]/blog/page.tsx, e assim por diante.
Mas não é uma simples mudança de ficheiros. Cada página tinha de receber o parâmetro locale, usar useTranslations() ou getTranslations() para os seus textos, e gerar metadados SEO na língua certa.
Foram produzidas mais de 385 chaves de tradução por língua, 1 155 no total, cobrindo a navegação, as páginas de oferta, a home, o blog, o SEO e toda a interface. Coerentes entre as três línguas, não palavra a palavra mas uma localização a sério.
A verdadeira armadilha: os artigos traduzidos têm slugs diferentes
Um site institucional multilingue é conteúdo fixo. Um blog multilingue é outra coisa. Um artigo francês vive em /fr/blog/2026/prix-saas-sur-mesure-suisse, a sua versão inglesa em /en/blog/2026/custom-saas-cost-switzerland. Os slugs são diferentes, e têm de ser, um slug francês numa página inglesa não posiciona em nada.
A convenção adotada: um ficheiro por língua, e um campo sourceId que liga a tradução ao original.
# data/blog/2026/prix-saas-sur-mesure-suisse.mdx
id: 'prix-saas-sur-mesure-suisse'
# data/blog/2026/prix-saas-sur-mesure-suisse.en.mdx
id: 'custom-saas-cost-switzerland'
sourceId: 'prix-saas-sur-mesure-suisse'
A locale deduz-se do sufixo do nome do ficheiro, através de um campo calculado do Contentlayer. A partir daí é possível, para qualquer artigo, encontrar os seus equivalentes nas outras línguas, e portanto gerar as tags hreflang corretas.
Resta o caso dos URLs cruzados. O que fazer quando o Google, ou um visitante, pede /en/blog/2026/prix-saas-sur-mesure-suisse, um slug francês sob um prefixo inglês? A regra aplicada: a língua pedida ganha. Se existir uma tradução inglesa, redireciona-se para ela. Só se não existir é que se remete para a língua dona do slug.
Escrita assim, a regra parece óbvia. Não era o que o meu código fazia, e volto a isso mais abaixo.
O que a IA me entregou e que eu não verifiquei
O Claude Code produziu um AlternatePathProvider, um contexto React suposto fornecer ao seletor de língua o slug traduzido exato da página atual. Limpo, bem tipado, com o seu AlternatePathSetter para o alimentar a partir de cada página.
Cinco meses depois, ao auditar o site, descobri que o AlternatePathSetter não era chamado em lado nenhum. O contexto estava sempre vazio. O seletor caía sistematicamente no seu valor de recurso, o caminho atual, e mesmo assim funcionava graças ao redirecionamento cruzado descrito acima.
Código morto que funciona por acidente. É exatamente o género de coisa que passa despercebida em review quando tudo parece correto e o site se comporta bem. Não partiu nada, mas ensinou-me algo. Quando a IA entrega uma abstração completa e elegante, verifique que está ligada, não apenas que está certa.
O bug que bloqueou a indexação de 40 páginas durante cinco meses
Eis a parte que eu não tinha previsto ao escrever este artigo, e de longe a mais cara.
Em agosto, a Search Console anuncia 50 páginas indexadas e 84 não indexadas. Ao investigar, destacam-se dois motivos: 25 páginas "Rastreada, atualmente não indexada" e 7 "Detetada, atualmente não indexada". Entre elas, as minhas páginas de entrada inglesa e portuguesa. O Google tinha ido vê-las, e depois decidido não as indexar.
Lancei um crawl completo do site, seguindo todos os links internos a partir do sitemap. Resultado: o crawler nunca chegou a uma única página /en/ ou /pt/. Não por estarem partidas, respondiam todas 200. Porque nada apontava para elas.
O culpado, o meu seletor de língua:
// Invisível para o Googlebot
<button aria-label={label} onClick={() => switchTo(code)}>
{flag}
</button>
Três bandeiras bonitas, um onClick, um router.replace. Para um utilizador, funciona perfeitamente. Para um crawler, essas bandeiras não existem. O Googlebot não executa os handlers de clique para descobrir URLs.
As minhas 40 páginas traduzidas não tinham portanto nenhum link de entrada em todo o site. Estavam ausentes do sitemap, retirado na altura para poupar orçamento de crawl, e ligadas a nada. O Google só as conhecia pelas tags hreflang, que são uma indicação e não um link, e que não transmitem qualquer autoridade.
A correção cabe em poucas linhas:
// Um link que o crawler pode seguir
<Link href={`/${code}${stripped}`} hrefLang={code} aria-label={label}>
{flag}
</Link>
Com uma subtileza que me custou uma iteração. O usePathname do next-intl não devolve o caminho real assim que se usam pathnames localizados, devolve o modelo interno. Num artigo, dá literalmente /blog/[year]/[id]. A minha primeira correção gerava portanto links href="/en/blog/[year]/[id]", ou seja links partidos, pior do que o ponto de partida. É preciso usar o usePathname bruto do next/navigation, retirar o prefixo de língua atual, e reconstruir o URL à mão.
E o redirecionamento cruzado, em tudo isto? Enviava o visitante para a língua dona do slug, não para aquela que ele tinha pedido. Na prática, a partir de um artigo inglês, clicar na bandeira francesa trazia de volta ao inglês. O comportamento passava despercebido enquanto o seletor era apenas um botão. Transformado em link rastreável, tornava-se um sinal contraditório enviado ao Google.
O que fica
O next-intl trata muito bem da i18n técnica, e uma IA monta-a em duas horas onde eu levava uma semana. Já não é aí que está a dificuldade.
A dificuldade está onde ninguém olha. Um componente pode ser acessível, testado, agradável de usar, e continuar totalmente invisível para um motor de busca. Uma abstração pode ser limpa e nunca estar ligada. Um redirecionamento pode funcionar cinco meses antes de percebermos que responde ao lado da pergunta.
Três reflexos que aplico agora em qualquer site multilingue:
- Cada versão linguística tem de ter pelo menos um link
<a href>de entrada. Uma taghreflangnão substitui um link. - O sitemap lista as três línguas. Poupar orçamento de crawl retirando páginas é abdicar do único canal de descoberta que resta quando a malha interna falha.
- Rastrear o próprio site depois de cada redesign. Trinta linhas de script chegam. Se o seu crawler não chega a uma página, o Google também não.
Esta migração i18n é tipicamente o género de fundação que se assenta desde o início quando se constrói uma plataforma SaaS à medida. Não uma funcionalidade para acrescentar depois, mas uma decisão de arquitetura que se paga cara quando é tomada tarde demais.
Episódio seguinte da série: transformar 13 episódios de podcast em 50 artigos de blog com IA.

Toni Dias
Engenheiro de software e parceiro técnico · AsuOs
Artigos relacionados

Retomar um projeto no-code ou vibe coded: por onde começar
Como pegar corretamente num projeto no-code ou vibe coded: os passos concretos, do balanço honesto até à auditoria, para um produto que finalmente se aguenta.

Juntei-me aos Mugiwara: a minha tripulação de agentes IA
Há algumas semanas que tenho a Nami, o Sanji, o Zoro, o Chopper, o Usopp, a Robin, o Franky, o Jimbei e o Brook na minha equipa. Bem-vindo à orquestração de agentes IA, onde cada membro da tripulação tem o seu papel, a sua especialidade e a sua biblioteca. O que isto muda não é apenas a minha produtividade. É toda a minha forma de construir um produto.
Pronto para transformar o seu negócio digital?
Toni Dias apoia-o na sua estratégia digital com soluções à medida.