//
Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Agrega soporte multiidioma a una aplicación Next.js 15+ con App Router con enrutamiento basado en configuración regional, carga de traducciones, detección de idioma a través de middleware y etiquetas hreflang optimizadas 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;
// Verifica si el pathname ya tiene un idioma
const pathnameHasLocale = i18nConfig.locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return NextResponse.next();
// Omite rutas que no son páginas
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api") ||
pathname.includes(".")
) {
return NextResponse.next();
}
// Redirige a la ruta con prefijo de idioma
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) {
// Reemplaza el idioma actual en la ruta con el nuevo idioma
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="Selecciona idioma"
>
{i18nConfig.locales.map((locale) => (
<option key={locale} value={locale}>
{languageNames[locale]}
</option>
))}
</select>
);
}[locale] como el primer segmento de ruta. Todas las páginas están anidadas bajo app/[locale]/, produciendo URLs como /en/posts y /de/posts.Accept-Language y redirige rutas no localizadas al prefijo de configuración regional correcto.import() dinámico. Las traducciones de cada idioma se dividen en código automáticamente.generateStaticParams pre-renderiza páginas para todos los idiomas en tiempo de compilación, lo que garantiza que la generación estática funcione con el enrutamiento de configuración regional.alternates.languages en la API de Metadata, informando a los motores de búsqueda sobre alternativas de idioma.Usando next-intl (Biblioteca completa):
// configuración de next-intl
// 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>;
}Interpolación y Plurales:
// 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
);
}
// Uso:
// t("Hello, {name}! You have {count} messages.", { name: "Alice", count: 5 })
// => "Hello, Alice! You have 5 messages."Soporte de 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 un tipo unión derivado de la configuración: type Locale = (typeof i18nConfig.locales)[number].Awaited<ReturnType<typeof getDictionary>> para el tipo completo.params es Promise<{ locale: string }> en Next.js 15+. Convierte a Locale después de la validación.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 debe excluir activos estáticos (_next, archivos con extensiones) para evitar redirecciones innecesarias.generateStaticParams debe devolver todos los idiomas. Los idiomas faltantes producirán 404s en producción a menos que dynamicParams esté habilitado.getDictionary directamente porque usa import() con módulos solo del servidor. Pasa traducciones como props desde un componente de servidor, o usa una biblioteca como next-intl que proporciona un hook del lado cliente./en/about (todos los idiomas con prefijo), otros prefieren /about para el predeterminado y /de/about para otros. El enfoque de middleware anterior siempre tiene prefijo. Para ocultar el idioma predeterminado, reescribe en lugar de redirigir.Intl.DateTimeFormat e Intl.NumberFormat con la configuración regional actual, no diccionarios basados en cadenas.import() dinámico para dividir el código por idioma y evitar cargar todos los idiomas a la vez.| Enfoque | Ventajas | Desventajas |
|---|---|---|
Enrutamiento manual [locale] + JSON | Sin dependencias, control total | Interpolación manual, sin plurales |
| next-intl | Completa, soporte RSC, type-safe | Dependencia adicional |
| i18next + react-i18next | Ecosistema enorme, maduro | Diseñado para el lado del cliente, SSR necesita adaptadores |
| Paraglide.js (inlang) | Tiempo de compilación, runtime minúsculo | Más nuevo, comunidad más pequeña |
| Crowdin o Lokalise | Plataforma de gestión de traducciones | Costo, herramientas externas |
app/[locale]/, produciendo URLs como /en/posts y /de/posts.locale se extrae de la URL y se usa para cargar el diccionario correcto.generateStaticParams pre-renderiza páginas para todos los idiomas configurados en tiempo de compilación.Accept-Language enviado por el navegador.import() dinámico habilita la división de código automática por idioma.dynamicParams esté habilitado.generateStaticParams.getDictionary usa import() dinámico del lado del servidor.next-intl que proporciona un hook useTranslations del lado cliente.export async function generateMetadata() {
return {
alternates: {
languages: {
en: "/en",
de: "/de",
ja: "/ja",
},
},
};
}alternates.languages genera etiquetas <link rel="alternate" hreflang="...">.export const i18nConfig = {
defaultLocale: "en",
locales: ["en", "de", "ja"],
} as const;
export type Locale = (typeof i18nConfig.locales)[number];
// Resultado: "en" | "de" | "ja"as const es crítica; sin ella, el tipo se amplía a string[].export type Dictionary = Awaited<
ReturnType<typeof getDictionary>
>;_next/static, imágenes y rutas de API se redirigirían a rutas con prefijo de idioma.["/((?!_next|api|favicon.ico).*)"] previene redirecciones innecesarias.const rtlLocales = ["ar", "he"];
const dir = rtlLocales.includes(locale) ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<body>{children}</body>
</html>
);dir en <html> según la configuración regional./about a /en/about siempre muestra el prefijo de idioma en la URL./en/about en /about sin cambiar la URL.useTranslations para componentes cliente sin prop-drilling.Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥