Internacionalización (i18n)
Receta
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.
Ejemplo funcional
Estructura del proyecto
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
Configuración i18n
// lib/i18n/config.ts
export const i18nConfig = {
defaultLocale: "en",
locales: ["en", "de", "ja"],
} as const;
export type Locale = (typeof i18nConfig.locales)[number];Diccionarios de traducción
// 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"
}
}Cargador de diccionarios
// 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 de detección de idioma
// 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).*)"],
};Layout de configuración regional
// 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>
);
}Página traducida
// 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>
);
}Selector de idioma (Componente cliente)
// 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>
);
}Análisis profundo
Cómo funciona
- El enrutamiento basado en configuración regional utiliza el segmento dinámico
[locale]como el primer segmento de ruta. Todas las páginas están anidadas bajoapp/[locale]/, produciendo URLs como/en/postsy/de/posts. - Middleware detecta la configuración regional preferida del encabezado
Accept-Languagey redirige rutas no localizadas al prefijo de configuración regional correcto. - Los diccionarios se cargan por solicitud en Server Components utilizando
import()dinámico. Las traducciones de cada idioma se dividen en código automáticamente. generateStaticParamspre-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.- Las etiquetas hreflang se agregan mediante el campo
alternates.languagesen la API de Metadata, informando a los motores de búsqueda sobre alternativas de idioma.
Variaciones
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>
);
}Notas de TypeScript
- Define
Localecomo un tipo unión derivado de la configuración:type Locale = (typeof i18nConfig.locales)[number]. - Los tipos de diccionario se infieren de la estructura JSON. Usa
Awaited<ReturnType<typeof getDictionary>>para el tipo completo. paramsesPromise<{ locale: string }>en Next.js 15+. Convierte aLocaledespués de la validación.- Para claves de traducción profundamente anidadas, considera un ayudante que proporcione autocompletado:
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;Trampas
- Middleware se ejecuta en cada solicitud. El regex de detección de idioma en
config.matcherdebe excluir activos estáticos (_next, archivos con extensiones) para evitar redirecciones innecesarias. generateStaticParamsdebe devolver todos los idiomas. Los idiomas faltantes producirán 404s en producción a menos quedynamicParamsesté habilitado.- Los componentes cliente no pueden llamar a
getDictionarydirectamente porque usaimport()con módulos solo del servidor. Pasa traducciones como props desde un componente de servidor, o usa una biblioteca comonext-intlque proporciona un hook del lado cliente. - El manejo de idioma predeterminado varía. Algunos prefieren
/en/about(todos los idiomas con prefijo), otros prefieren/aboutpara el predeterminado y/de/aboutpara otros. El enfoque de middleware anterior siempre tiene prefijo. Para ocultar el idioma predeterminado, reescribe en lugar de redirigir. - El formato de fecha, número y moneda debe usar
Intl.DateTimeFormateIntl.NumberFormatcon la configuración regional actual, no diccionarios basados en cadenas. - Los archivos de traducción grandes aumentan el tamaño del paquete. Usa
import()dinámico para dividir el código por idioma y evitar cargar todos los idiomas a la vez.
Alternativas
| 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 |
Preguntas frecuentes
¿Cómo funciona el enrutamiento basado en configuración regional con el segmento dinámico [locale]?
- Todas las páginas están anidadas bajo
app/[locale]/, produciendo URLs como/en/postsy/de/posts. - El parámetro
localese extrae de la URL y se usa para cargar el diccionario correcto. generateStaticParamspre-renderiza páginas para todos los idiomas configurados en tiempo de compilación.
¿Cómo detecta el middleware el idioma preferido del usuario?
- Lee el encabezado
Accept-Languageenviado por el navegador. - Analiza el encabezado en códigos de idioma con pesos de prioridad y ordena por prioridad.
- Se utiliza la primera configuración regional coincidente de la configuración; de lo contrario, se devuelve la configuración regional predeterminada.
¿Por qué se cargan diccionarios con import() dinámico en lugar de importaciones estáticas?
import()dinámico habilita la división de código automática por idioma.- Solo se cargan las traducciones de la configuración regional solicitada, no todos los idiomas a la vez.
- Esto mantiene los tamaños de paquete pequeños, especialmente con muchos idiomas.
Trampa: ¿Qué sucede si generateStaticParams no incluye un idioma?
- Ese idioma generará un 404 en producción a menos que
dynamicParamsesté habilitado. - Siempre devuelve todos los idiomas configurados desde
generateStaticParams. - Los idiomas faltantes fallarán silenciosamente sin advertencia en tiempo de compilación.
¿Pueden los componentes cliente llamar a getDictionary directamente?
- No, porque
getDictionaryusaimport()dinámico del lado del servidor. - Pasa traducciones como props desde un componente de servidor padre al componente cliente.
- Alternativamente, usa una biblioteca como
next-intlque proporciona un hookuseTranslationsdel lado cliente.
¿Cómo se agregan etiquetas hreflang para SEO con la API de Metadata?
export async function generateMetadata() {
return {
alternates: {
languages: {
en: "/en",
de: "/de",
ja: "/ja",
},
},
};
}- El campo
alternates.languagesgenera etiquetas<link rel="alternate" hreflang="...">.
¿Cómo escribes el tipo Locale derivado de la configuración en TypeScript?
export const i18nConfig = {
defaultLocale: "en",
locales: ["en", "de", "ja"],
} as const;
export type Locale = (typeof i18nConfig.locales)[number];
// Resultado: "en" | "de" | "ja"- La aserción
as constes crítica; sin ella, el tipo se amplía astring[].
¿Cómo escribes el valor de retorno del diccionario en TypeScript?
export type Dictionary = Awaited<
ReturnType<typeof getDictionary>
>;- El tipo se infiere de la estructura JSON automáticamente.
- No se necesita definición de interfaz manual.
Trampa: ¿Por qué el matcher de middleware debe excluir activos estáticos y rutas de API?
- Middleware se ejecuta en cada solicitud que coincide con el patrón.
- Sin exclusiones, las solicitudes a
_next/static, imágenes y rutas de API se redirigirían a rutas con prefijo de idioma. - El matcher
["/((?!_next|api|favicon.ico).*)"]previene redirecciones innecesarias.
¿Cómo se manejan idiomas RTL (de derecha a izquierda) como árabe o hebreo?
const rtlLocales = ["ar", "he"];
const dir = rtlLocales.includes(locale) ? "rtl" : "ltr";
return (
<html lang={locale} dir={dir}>
<body>{children}</body>
</html>
);- Establece el atributo
diren<html>según la configuración regional.
¿Cuál es la diferencia entre redirigir y reescribir para el idioma predeterminado?
- Redirigir
/abouta/en/aboutsiempre muestra el prefijo de idioma en la URL. - Reescribir sirve contenido de
/en/abouten/aboutsin cambiar la URL. - Ocultar el idioma predeterminado (reescribir) es común; mostrarlo (redirigir) es más simple de implementar.
¿Cómo simplifica la biblioteca next-intl i18n en comparación con el enfoque manual?
- Proporciona
useTranslationspara componentes cliente sin prop-drilling. - Maneja interpolación, plurales y formato de número/fecha de inmediato.
- Se integra nativamente con el App Router y Server Components.
Relacionado
- SEO - etiquetas hreflang y metadatos específicos de idioma
- Autenticación - redirecciones de autenticación conscientes del idioma
- Despliegue - servir compilaciones específicas de idioma
- Documentación de internacionalización de Next.js