URLs traduzidas com next-intl sem duplicar rotas
Como servir /estudos em português e /blog em inglês a partir de um único arquivo de rota no App Router.
3 min de leitura
A primeira versão de qualquer site bilíngue costuma cair num destes dois buracos:
ou as URLs ficam em inglês para todo mundo (/pt/blog), ou você duplica a árvore de
rotas para ter /estudos e /blog de verdade. O primeiro é preguiçoso, o segundo
dobra a manutenção.
O next-intl resolve isso com pathnames, e vale entender exatamente o que ele faz.
O conceito: pathname interno vs. externo
A ideia central é separar dois nomes para a mesma coisa:
- Pathname interno — o caminho das pastas em
app/. Sempre em inglês, nunca aparece para o usuário. - Pathname externo — o que vai na barra de endereço, e que muda por idioma.
Você declara o mapeamento uma vez:
// src/i18n/routing.ts
export const routing = defineRouting({
locales: ["pt", "en"],
defaultLocale: "pt",
localePrefix: "as-needed",
pathnames: {
"/": "/",
"/blog": { pt: "/estudos", en: "/blog" },
"/blog/[slug]": { pt: "/estudos/[slug]", en: "/blog/[slug]" },
},
});Com localePrefix: "as-needed", o idioma padrão perde o prefixo. O resultado:
| Arquivo | URL em PT | URL em EN |
|---|---|---|
app/[locale]/blog/page.tsx | /estudos | /en/blog |
app/[locale]/blog/[slug]/page.tsx | /estudos/meu-post | /en/blog/meu-post |
Um arquivo, duas URLs. Nada é duplicado.
Os links precisam vir do next-intl
Este é o detalhe que quebra a maior parte das implementações. O Link do
next/link não sabe nada sobre o mapeamento — ele geraria /blog literalmente.
Você precisa dos wrappers:
// src/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í, você escreve sempre o pathname interno e deixa a tradução por conta da lib:
<Link href="/blog">Estudos</Link>
<Link href={{ pathname: "/blog/[slug]", params: { slug } }}>Ler</Link>O bônus é tipagem: um erro de digitação em /blogg vira erro de compilação, não
um 404 descoberto em produção.
O proxy e os arquivos com ponto
No Next.js 16 o middleware.ts virou proxy.ts. O matcher recomendado ignora
qualquer caminho que contenha ponto — o que é ótimo para favicon.ico e péssimo
para /rss.xml, que é uma rota de verdade e precisa passar pela tradução.
export const config = {
matcher: [
"/((?!api|trpc|og|_next|_vercel|.*\\..*).*)",
// rss.xml tem ponto no nome: precisa entrar de volta explicitamente
"/rss.xml",
"/(pt|en)/rss.xml",
],
};O inverso também acontece: rotas que não devem ser localizadas — como um
endpoint que gera imagem OG — precisam sair do matcher, senão /og vira /pt/og
e retorna 404.
Tradução parcial sem página de erro
Sobra um caso que a lib não resolve sozinha: o post existe em português, mas ainda não em inglês. Trocar o idioma levaria o leitor direto a um 404.
A correção cabe na própria rota:
const entry = getEntry(locale, "blog", slug);
if (!entry) {
const other = routing.locales.find((candidate) => candidate !== locale);
if (other && hasEntry(other, "blog", slug)) {
// Existe, só não neste idioma: manda para o índice em vez de errar.
redirect({ href: "/blog", locale });
}
notFound();
}Três linhas, e o leitor cai numa lista útil em vez de um beco sem saída.
O que eu faria diferente
Slugs continuam compartilhados entre os idiomas — /estudos/localized-routes e
/en/blog/localized-routes. Para SEO em português o ideal seria traduzir o slug
também, mas isso exige uma tabela de equivalência entre os dois. Como a troca de
idioma depende do slug bater, deixei compartilhado por enquanto. É a próxima coisa
que eu mexo.