gcampes
Back

Três idiomas num fim de semana: como localizei meu site em Next.js com App Router + Next Intl

Sep 25, 2025 09/25/2025

nextjsi18nlocalizationreactreact-intltailwindmdx

Por que localização?

Eu sou brasileiro, moro na Alemanha desde 2017 e trabalho pra uma empresa dos EUA. Só que meu site era só em inglês. Tava com cara de oportunidade desperdiçada.

Eu também queria uma desculpa pra sair do Pages Router. No trabalho a gente tá preso nele faz tempo, e no meu site pessoal era a mesma história. Localização parecia o projeto com escopo perfeito pra forçar a migração: App Router + Next Intl, num fim de semana.


Routing e middleware

Tem algumas formas de lidar com locale na URL: domínios separados (gcampes.de), subdomínios (de.gcampes.me) ou prefixo no path. Eu fui de Next Intl com routing por prefixo, que é a opção mais simples. O locale entra no começo de toda URL: /en/blog, /pt/experience/2022-01-01-seatgeek.

Todos os locales suportados ficam num único arquivo de config:

// i18n/routing.ts
import { defineRouting } from "next-intl/routing";
 
export const locales = ["en", "pt", "de"];
 
export const routing = defineRouting({
  locales,
  defaultLocale: "en",
});

E eu uso um middleware.ts pra garantir que cada request vá pro lugar certo:

// middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
 
export default createMiddleware(routing);
 
export const config = {
  matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)"
};

Esse matcher diz pro Next.js rodar o middleware em todas as rotas, menos endpoints de API, paths internos do Next.js (_next, _vercel) e arquivos estáticos, qualquer coisa com extensão tipo .css ou .png.

Na prática, isso significa que um /blog pelado redireciona pra /en/blog por padrão. Se o navegador do visitante mandar um header Accept-Language com português, o middleware redireciona pra /pt/blog.

Mais uma coisa sobre routing: não prefixe links na mão. Cria um arquivo i18n/navigation.ts, importa e esquece:

// i18n/navigation.ts
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
 
export const { Link, redirect, usePathname, useRouter, getPathname } =
  createNavigation(routing);

Aí é só usar o componente Link que o prefixo do locale entra automático:

import { Link } from "@/i18n/navigation";
 
<Link href="/">
  {t("home")}
</Link>

Messages (traduções)

As strings de tradução ficam em arquivos JSON simples, um por locale. Sem firula:

{
  "metadata": {
    "title": "Senior Software Engineer in Berlin | gcampes",
    "description": "Gabriel Carvalho de Campes, Senior Software Engineer in Berlin, Germany."
  },
  "navigation": {
    "home": "Home",
    "experience": "Experience"
  },
  "homepage": {
    "experienceDescription": "I have been working as a software engineer for {years} years."
  }
}

As versões em português e alemão espelham essa estrutura. Repara no placeholder {years} em experienceDescription. O Next Intl interpola isso em tempo de render, então cada locale pode posicionar essas partes onde a gramática pedir.


Request config

O arquivo de request config diz pro Next Intl como resolver o locale atual e carregar as messages certas em cada render server-side. Achei essa parte meio mal explicada na documentação, então aqui vai a versão que eu queria ter lido de cara.

Se você colocar o arquivo em i18n/request.ts, o Next Intl encontra ele automaticamente, zero config:

// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";
 
export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested)
    ? requested
    : routing.defaultLocale;
 
  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

Como eu segui a convenção de nome, no next.config.ts bastou usar createNextIntlPlugin() sem passar argumento nenhum. Se o arquivo estivesse em outro lugar, aí sim eu precisaria passar o path, tipo createNextIntlPlugin("./your-path/your-file.ts").


Layout e provider

O layout [locale] envolve tudo com um NextIntlClientProvider. É isso que faz hooks client-side como useRouter e useLocale funcionarem.

Como todas as minhas traduções carregam no server via getTranslations, eu não passo messages pro provider. Se você usar useTranslations em client components, aí vai precisar passar.

// app/[locale]/layout.tsx
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { setRequestLocale } from "next-intl/server";
import { routing } from "@/i18n/routing";
 
export default async function LocaleLayout({ children, params }) {
  const { locale } = params;
 
  if (!hasLocale(routing.locales, locale)) throw new Error("Unknown locale");
 
  setRequestLocale(locale);
 
  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider locale={locale}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

Depois desse setup, você pode usar getTranslations em qualquer server component:

import { getTranslations } from 'next-intl/server';
 
export default async function HomePage() {
  const t = await getTranslations('homepage');
  return <h1>{t('name')}</h1>;
}

Pra client components, use o hook useTranslations. Nesse caso, passe messages pro provider.


Metadata por locale

Você também vai querer <title> e tags <meta> localizadas. Sem isso, o Google indexa tudo como se fosse inglês. O generateMetadata deixa isso bem direto:

export async function generateMetadata({ params }) {
  const { locale } = await params;
 
  const t = await getTranslations({ locale, namespace: "metadata" });
 
  return {
    title: t("title"),
    description: t("description"),
    alternates: {
      languages: {
        en: "https://gcampes.me/en",
        pt: "https://gcampes.me/pt",
        de: "https://gcampes.me/de"
      }
    }
  };
}

Rich text com .rich

Essa aqui eu quase deixei passar. Normalmente, quando você quer estilizar parte de uma string traduzida, tipo deixar o nome da empresa colorido, você quebraria a tradução em pedaços e envolveria tudo em JSX na mão. Com .rich, dá pra colocar tags leves direto na message e mapear isso pra componentes React na hora do render:

// en.json
"experience": {
  "title": "{denomination} {title} at <company>{companyName}</company>"
}
 
// pt.json
"experience": {
  "title": "{title} {denomination} em <company>{companyName}</company>"
}

E renderizar assim:

<h1>
  {t.rich("title", {
    denomination: "Senior",
    title: "Software Engineer",
    companyName: "SeatGeek",
    company: (chunks) => <span className="text-brand">{chunks}</span>
  })}
</h1>

Repara como o português inverte a ordem, "Software Engineer Senior" em vez de "Senior Software Engineer", e troca "at" por "em". A tag <company> continua grudada no nome da empresa nos dois casos.


Datas com date-fns

Pra formatar datas eu usei date-fns, porque ele já fazia parte da minha stack. O Next Intl tem formatter de data embutido e talvez simplifique isso. Mas, pra esse projeto, usar o que eu já conhecia ajudou a manter o escopo sob controle.

import { format } from "date-fns";
import { ptBR, enUS, de } from "date-fns/locale";
 
function getDateFnsLocale(appLocale) {
  switch (appLocale) {
    case "pt": return ptBR;
    case "de": return de;
    default: return enUS;
  }
}
 
<p>
  {t("fromTo", {
    from: format(new Date("2021-05-01"), "MMMM yyyy", { locale: getDateFnsLocale(locale) }),
    to: format(new Date("2024-08-01"), "MMMM yyyy", { locale: getDateFnsLocale(locale) })
  })}
</p>

As messages cuidam da gramática, tipo "De {from} até {to}", e o date-fns cuida do nome dos meses.


Posts em MDX por locale

Cada locale tem sua própria pasta:

blog/
  en/
    my-post.mdx
  pt/
    my-post.mdx
  de/
    my-post.mdx

Um helper pequeno lê o arquivo certo do disco usando o fs do Node:

import fs from "fs";
import matter from "gray-matter";
 
export function getPost(locale, slug) {
  const raw = fs.readFileSync(`blog/${locale}/${slug}.mdx`, "utf8");
  return matter(raw);
}

Nada muito esperto. Mesmo slug, pasta diferente. Dá até pra comparar a versão em inglês com a em português direto no diff.

Eu até cogitei manter um arquivo único por post, com flags de locale no frontmatter, mas separar um arquivo por locale deixou os diffs bem mais limpos e ainda me permitiu delegar traduções específicas sem precisar compartilhar o repo inteiro.


Pegadinhas que eu encontrei

Metadata

Sempre chame setRequestLocale(locale) dentro de generateMetadata. Sem isso, o Next Intl cai no locale padrão, e aí sua página em alemão pode acabar com metadata em inglês:

import { setRequestLocale } from "next-intl/server";
 
export async function generateMetadata({ params }) {
  const { locale } = await params;
  setRequestLocale(locale);
  // ...
}

Static site generation

Por padrão, o App Router gera páginas sob demanda. Se você quiser páginas pré-geradas no build, com load mais rápido e funcionando sem servidor, precisa dizer pro Next.js todas as combinações válidas de parâmetros de rota usando generateStaticParams. Na minha rota de índice:

export async function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

Pra rotas com vários segmentos dinâmicos, como posts do blog, você precisa gerar toda combinação de locale com slug:

export async function generateStaticParams() {
  const routes: { locale: string; slug: string }[] = [];
 
  routing.locales.forEach((locale) => {
    const posts = getPosts(locale);
    posts.forEach((post) => {
      const { data } = matter(post);
      routes.push({ locale, slug: data.slug });
    });
  });
 
  return routes;
}

Fechando

No fim, tudo isso levou um fim de semana. A maior parte do tempo foi tentando entender request.ts e as pegadinhas do generateStaticParams. A API do Next Intl em si é pequena e, depois que você liga os fios, ela simplesmente sai da frente.

E, sinceramente, a melhor parte foi trocar pro português e ver meu próprio site na minha língua pela primeira vez.