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.
A abstração que funciona para humanos e não para robôs
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.
Ao auditar o site cinco meses depois, fui ver onde estava realmente ligado. A resposta: no layout dos artigos de blog, e em mais lado nenhum. Na home, nas páginas de oferta, nas tags ou nos casos, o contexto fica vazio e o seletor cai no caminho atual.
Mas o verdadeiro detalhe está noutro sítio. Mesmo nas páginas onde está ligado, o AlternatePathSetter alimenta o contexto dentro de um useEffect:
// components/AlternatePathSetter.tsx
useEffect(() => {
setAlternatePaths(paths)
return () => setAlternatePaths({})
}, [paths, setAlternatePaths])
Um useEffect corre depois da hidratação, do lado do cliente. O HTML gerado pelo servidor, aquele que o Googlebot lê, carrega portanto sempre o caminho de recurso, nunca o slug traduzido exato.
A abstração faz exatamente o seu trabalho para um humano que clica. Não faz absolutamente nada para um crawler. É o mesmo ponto cego que me custou cinco meses de indexação, num sítio onde não estava à espera dele, e também não o tinha visto.
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, corretamente ligada, e só produzir efeito depois da hidratação, portanto nunca para um motor de busca. 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

Do protótipo de IA ao produto vendável: o método de industrialização
Como transformar um protótipo de IA, no-code ou feito à mão num produto sólido e vendável: auditoria, o que se mantém, fundações, testes e CI/CD.

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.
Pronto para transformar o seu negócio digital?
Toni Dias apoia-o na sua estratégia digital com soluções à medida.