//
Busque em todas as páginas da documentação
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Adicione suporte multilíngue a uma aplicação Next.js 15+ com App Router, incluindo roteamento baseado em locale, carregamento de traduções, detecção de locale via middleware e tags hreflang amigáveis para SEO.
app/
[locale]/
layout.tsx
page.tsx
posts/
page.tsx
lib/
i18n/
config.ts
get-dictionary.ts
dictionaries/
en.json
de.json
ja.json
middleware.ts
// lib/i18n/config.ts
export const i18nConfig = {
defaultLocale: "en",
locales: ["en", "de", "ja"],
} as const;
export type Locale = (typeof i18nConfig.locales)[number];// lib/i18n/dictionaries/en.json
{
"common": {
"title": "My App",
"nav": {
"home": "Home",
"posts": "Posts",
"about": "About"
}
},
"home": {
"heading": "Welcome to My App",
"description": "A modern web application"
},
"posts": {
"heading": "All Posts",
"empty": "No posts found"
}
}// lib/i18n/dictionaries/de.json
{
"common": {
"title": "Meine App",
"nav": {
"home": "Startseite",
"posts": "Beiträge",
"about": "Über uns"
}
},
"home": {
"heading": "Willkommen bei Meine App",
"description": "Eine moderne Webanwendung"
},
"posts": {
"heading": "Alle Beiträge",
"empty": "Keine Beiträge gefunden"
}
}// lib/i18n/get-dictionary.ts
import type { Locale } from "./config";
const dictionaries = {
en: () => import("./dictionaries/en.json").then((m) => m.default),
de: () => import("./dictionaries/de.json").then((m) => m.default),
ja: () => import("./dictionaries/ja.json").then((m) => m.default),
};
export async function getDictionary(locale: Locale) {
return dictionaries[locale]();
}
export type Dictionary = Awaited<ReturnType<typeof getDictionary>>;// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { i18nConfig } from "./lib/i18n/config";
function getPreferredLocale(request: NextRequest): string {
const acceptLanguage = request.headers.get("accept-language");
if (!acceptLanguage) return i18nConfig.defaultLocale;
const preferred = acceptLanguage
.split(",")
.map((lang) => {
const [code, priority] = lang.trim().split(";q=");
return {
code: code.split("-")[0].toLowerCase(),
priority: priority ? parseFloat(priority) : 1.0,
};
})
.sort((a, b) => b.priority - a.priority);
const match = preferred.find((p) =>
i18nConfig.locales.includes(p.code as any)
);
return match?.code ?? i18nConfig.defaultLocale;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if pathname already has a locale
const pathnameHasLocale = i18nConfig.locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return NextResponse.next();
// Skip non-page paths
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api") ||
pathname.includes(".")
) {
return NextResponse.next();
}
// Redirect to locale-prefixed path
const locale = getPreferredLocale(request);
const newUrl = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.redirect(newUrl);
}
export const config = {
matcher: ["/((?!_next|api|favicon.ico).*)"],
};// app/[locale]/layout.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { i18nConfig, type Locale } from "@/lib/i18n/config";
import { getDictionary } from "@/lib/i18n/get-dictionary";
type Props = {
children: React.ReactNode;
params: Promise<{ locale: string }>;
};
export async function generateStaticParams() {
return i18nConfig.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const dict = await getDictionary(locale as Locale);
return {
title: {
default: dict.common.title,
template: `%s | ${dict.common.title}`,
},
alternates: {
languages: Object.fromEntries(
i18nConfig.locales.map((l) => [l, `/${l}`])
),
},
};
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
if (!i18nConfig.locales.includes(locale as Locale)) {
notFound();
}
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}// app/[locale]/page.tsx
import { getDictionary } from "@/lib/i18n/get-dictionary";
import type { Locale } from "@/lib/i18n/config";
type Props = {
params: Promise<{ locale: string }>;
};
export default async function HomePage({ params }: Props) {
const { locale } = await params;
const dict = await getDictionary(locale as Locale);
return (
<main>
<h1>{dict.home.heading}</h1>
<p>{dict.home.description}</p>
</main>
);
}// components/language-switcher.tsx
"use client";
import { usePathname, useRouter } from "next/navigation";
import { i18nConfig, type Locale } from "@/lib/i18n/config";
const languageNames: Record<Locale, string> = {
en: "English",
de: "Deutsch",
ja: "日本語",
};
export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
const pathname = usePathname();
const router = useRouter();
function switchLocale(newLocale: Locale) {
// Replace current locale in path with new locale
const segments = pathname.split("/");
segments[1] = newLocale;
router.push(segments.join("/"));
}
return (
<select
value={currentLocale}
onChange={(e) => switchLocale(e.target.value as Locale)}
aria-label="Select language"
>
{i18nConfig.locales.map((locale) => (
<option key={locale} value={locale}>
{languageNames[locale]}
</option>
))}
</select>
);
}[locale] como o primeiro segmento do caminho. Todas as páginas são aninhadas sob app/[locale]/, produzindo URLs como /en/posts e /de/posts.Accept-Language e redireciona caminhos não localizados para o prefixo de locale correto.import() dinâmico. As traduções de cada locale são divididas em código automaticamente.generateStaticParams pré-renderiza páginas para todos os locales no tempo de compilação, garantindo que a geração estática funcione com roteamento de locale.alternates.languages na Metadata API, informando aos motores de busca sobre alternativas de idioma.Usando next-intl (Biblioteca Completa):
// next-intl setup
// i18n/request.ts
import { getRequestConfig } from "next-intl/server";
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../messages/${locale}.json`)).default,
}));// app/[locale]/page.tsx
import { useTranslations } from "next-intl";
export default function HomePage() {
const t = useTranslations("home");
return <h1>{t("heading")}</h1>;
}Interpolação e Plurais:
// lib/i18n/translate.ts
type TranslationValues = Record<string, string | number>;
export function t(
template: string,
values?: TranslationValues
): string {
if (!values) return template;
return Object.entries(values).reduce(
(result, [key, value]) =>
result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value)),
template
);
}
// Usage:
// t("Hello, {name}! You have {count} messages.", { name: "Alice", count: 5 })
// => "Hello, Alice! You have 5 messages."Suporte a Idiomas RTL:
// app/[locale]/layout.tsx
const rtlLocales = ["ar", "he"];
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
const dir = rtlLocales.includes(locale) ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<body>{children}</body>
</html>
);
}Locale como um tipo union derivado da configuração: type Locale = (typeof i18nConfig.locales)[number].Awaited<ReturnType<typeof getDictionary>> para o tipo completo.params é Promise<{ locale: string }> no Next.js 15+. Converta para Locale após a validação.type NestedKeyOf<T> = T extends object
? { [K in keyof T]: K extends string
? T[K] extends object
? `${K}.${NestedKeyOf<T[K]>}`
: K
: never
}[keyof T]
: never;config.matcher deve excluir assets estáticos (_next, arquivos com extensões) para evitar redirecionamentos desnecessários.generateStaticParams deve retornar todos os locales. Locales ausentes produzirão 404 na produção, a menos que dynamicParams esteja habilitado.getDictionary diretamente porque ele usa import() com módulos exclusivos do servidor. Passe as traduções como props de um Server Component, ou use uma biblioteca como next-intl que fornece um hook para o lado do cliente./en/about (todos os locales prefixados), outros preferem /about para o padrão e /de/about para os outros. A abordagem de middleware acima sempre prefixa. Para ocultar o locale padrão, use rewrite em vez de redirect.Intl.DateTimeFormat e Intl.NumberFormat com o locale atual, não dicionários baseados em string.import() dinâmico para dividir o código por locale e evitar carregar todas as línguas de uma vez.| Abordagem | Prós | Contras |
|---|---|---|
Roteamento manual [locale] + JSON | Sem dependências, controle total | Interpolação manual, sem plurais |
| next-intl | Completo, suporte a RSC, type-safe | Dependência extra |
| i18next + react-i18next | Grande ecossistema, maduro | Projetado para o lado do cliente, SSR precisa de adaptadores |
| Paraglide.js (inlang) | Tempo de compilação, runtime mínimo | Mais novo, comunidade menor |
| Crowdin ou Lokalise | Plataforma de gerenciamento de traduções | Custo, ferramentas externas |
app/[locale]/, produzindo URLs como /en/posts e /de/posts.locale é extraído da URL e usado para carregar o dicionário correto.generateStaticParams pré-renderiza páginas para todos os locales configurados no tempo de compilação.Accept-Language enviado pelo navegador.import() dinâmico permite a divisão automática de código por locale.dynamicParams esteja habilitado.generateStaticParams.getDictionary usa import() do lado do servidor.next-intl que fornece um hook useTranslations para o lado do cliente.export async function generateMetadata() {
return {
alternates: {
languages: {
en: "/en",
de: "/de",
ja: "/ja",
},
},
};
}alternates.languages gera tags <link rel="alternate" hreflang="...">.export const i18nConfig = {
defaultLocale: "en",
locales: ["en", "de", "ja"],
} as const;
export type Locale = (typeof i18nConfig.locales)[number];
// Result: "en" | "de" | "ja"as const é crucial; sem ela, o tipo se expande para string[].export type Dictionary = Awaited<
ReturnType<typeof getDictionary>
>;_next/static, imagens e rotas de API seriam redirecionadas para caminhos com prefixo de locale.["/((?!_next|api|favicon.ico).*)"] impede redirecionamentos desnecessários.const rtlLocales = ["ar", "he"];
const dir = rtlLocales.includes(locale) ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<body>{children}</body>
</html>
);dir em <html> com base no locale./about para /en/about sempre mostra o prefixo do locale na URL./en/about em /about sem alterar a URL.useTranslations para Componentes Cliente sem prop-drilling.Revisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥