Início
VA.
Voltar para Estudos

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:

ArquivoURL em PTURL 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.

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.

Tags

  • Next.js
  • i18n
  • next-intl