Next.js
Internacionalización en App Router
Explica cómo modelar locales, rutas, traducciones, formatos, metadata, caché y contenido localizado dentro del App Router de Next.js.
- Última actualización
- Actualizada
- Nivel
- Aplicación
Next.js
Explica cómo modelar locales, rutas, traducciones, formatos, metadata, caché y contenido localizado dentro del App Router de Next.js.
Internacionalizar una aplicación no consiste únicamente en traducir strings. Debes modelar idioma, región, formato, URLs, metadata, caché, contenido, fallback y navegación. En App Router, el locale suele formar parte de la ruta para que cada versión sea compartible, rastreable y renderizable en servidor.
request URL + cookie + Accept-Language
↓
locale negotiation
↓
route /[lang]/...
↓
load messages and regional data
↓
render HTML with lang, metadata and alternatesUna URL explícita debe ser la fuente principal:
/es/servicios
/en/servicesEsto permite:
Combinación de idioma y, opcionalmente, región:
es
es-CO
en-US
pt-BRConversión de contenido textual.
Adaptación completa: fechas, números, moneda, unidades, dirección del texto, imágenes y convenciones culturales.
Proceso para seleccionar locale cuando la URL todavía no lo indica.
app/
└─ [lang]/
├─ layout.tsx
├─ page.tsx
├─ services/page.tsx
└─ portfolio/[slug]/page.tsxLayout:
const supportedLocales = ["es", "en"] as const;
type Locale = (typeof supportedLocales)[number];
function isLocale(value: string): value is Locale {
return supportedLocales.includes(value as Locale);
}
export default async function LocaleLayout({
children,
params,
}: LayoutProps<"/[lang]">) {
const { lang } = await params;
if (!isLocale(lang)) {
notFound();
}
return (
<html lang={lang}>
<body>{children}</body>
</html>
);
}La carpeta dinámica captura un string; valida siempre. lang en <html> ayuda a lectores de pantalla, spellcheck y buscadores.
// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const PUBLIC_FILE = /\.[^/]+$/;
const locales = ["es", "en"];
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api") ||
PUBLIC_FILE.test(pathname)
) {
return NextResponse.next();
}
const hasLocale = locales.some(
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),
);
if (hasLocale) {
return NextResponse.next();
}
const locale = negotiateLocale(request) ?? "es";
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(url);
}Accept-Language.El Proxy solo negocia/redirect. La route valida nuevamente el locale.
Ejemplo:
es-CO,es;q=0.9,en;q=0.8Incluye prioridades (q). No lo parses con split(",")[0] si necesitas una negociación robusta. Usa una librería pequeña o implementa matching conforme a locales soportados.
El header es una preferencia, no identidad ni región legal. Un usuario en Colombia puede preferir inglés.
Cuando el usuario cambia idioma:
"use server";
export async function setLocale(locale: Locale, returnPath: string) {
const parsed = localeSchema.parse(locale);
const store = await cookies();
store.set("locale", parsed, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 365,
});
redirect(buildLocalizedPath(returnPath, parsed));
}La cookie recuerda preferencia, pero la URL explícita sigue siendo la versión compartible.
messages/
├─ es.json
└─ en.json{
"navigation": {
"home": "Inicio",
"portfolio": "Proyectos"
}
}Carga por locale:
const dictionaries = {
es: () => import("@/messages/es.json").then((module) => module.default),
en: () => import("@/messages/en.json").then((module) => module.default),
};
export async function getDictionary(locale: Locale) {
return dictionaries[locale]();
}El mapa explícito evita imports construidos desde input y permite code splitting.
Un JSON grande sin tipos produce keys rotas en runtime. Opciones:
No uses strings como "navigation.home" dispersas sin tooling si el proyecto crece.
Carga traducciones en servidor:
export default async function ServicesPage({
params,
}: PageProps<"/[lang]/services">) {
const { lang } = await params;
const locale = parseLocale(lang);
const dictionary = await getDictionary(locale);
return <h1>{dictionary.services.title}</h1>;
}No necesitas enviar el catálogo completo al cliente. Pasa solo mensajes que una región interactiva necesita.
<InteractiveSearch
labels={{
input: dictionary.search.input,
submit: dictionary.search.submit,
empty: dictionary.search.empty,
}}
/>Un provider global con miles de mensajes aumenta RSC payload y bundle/state cliente. Divide por namespace o route.
new Intl.NumberFormat("es-CO", {
style: "currency",
currency: "COP",
}).format(125000);Locale y currency son conceptos distintos. Un usuario en inglés puede pagar COP.
new Intl.DateTimeFormat("es-CO", {
dateStyle: "long",
timeZone: "America/Bogota",
}).format(date);La timezone debe ser explícita cuando servidor y cliente podrían diferir. Evita hydration mismatch.
No concatena simplemente “s”:
const rules = new Intl.PluralRules(locale);Bibliotecas ICU MessageFormat ayudan con plural/gender/select.
Traducir UI y traducir contenido son pipelines diferentes.
UI messages
→ versionados con código
CMS content
→ documentos por locale / field translationsDecide:
No muestres contenido español bajo URL inglesa sin marcar fallback; genera confusión SEO y de producto.
/es/servicios
/en/servicesNecesitas un resolver:
const routeMap = {
services: {
es: "/es/servicios",
en: "/en/services",
},
};Para contenido CMS, guarda slug por locale y un ID común de traducción. El selector de idioma debe navegar a la variante equivalente, no siempre a home.
export async function generateMetadata({
params,
}: PageProps<"/[lang]/services">): Promise<Metadata> {
const { lang } = await params;
const locale = parseLocale(lang);
const messages = await getDictionary(locale);
return {
title: messages.services.seoTitle,
description: messages.services.seoDescription,
alternates: {
canonical: `/${locale}/${localizedServicesSlug(locale)}`,
languages: {
"es-CO": "/es/servicios",
"en-US": "/en/services",
},
},
openGraph: {
locale: locale === "es" ? "es_CO" : "en_US",
},
};
}Cada versión debe referenciar a todas y a sí misma. Incluye x-default cuando existe una página neutral de selección.
Genera una entrada por versión indexable. No incluyas traducciones incompletas o que redirigen.
lastModified puede variar por locale si se actualizan en fechas distintas.
Una función cacheada debe incluir locale:
async function getLocalizedProject(slug: string, locale: Locale) {
"use cache";
cacheLife("hours");
cacheTag(`project:${slug}:${locale}`);
return cms.getProject({ slug, locale });
}Olvidar locale en argumentos/key puede servir español en una ruta inglesa.
Invalida la variante y cualquier índice/sitemap relacionado.
Locale no debe decidir automáticamente:
Usa país/mercado explícito, perfil o dirección. Una preferencia de idioma no prueba ubicación.
Para árabe/hebreo:
<html lang={locale} dir={isRtl(locale) ? "rtl" : "ltr"}>CSS debe usar propiedades lógicas:
.card {
margin-inline-start: 1rem;
padding-inline: 1rem;
}Prueba iconos direccionales, tablas, gráficos y navegación. No inviertas logos o contenido que no cambia semánticamente.
lang correcto en el documento.lang en fragmentos de otro idioma.La URL no es compartible ni rastreable.
Colombia no implica español y español no implica COP.
Payload cliente excesivo.
Orden/plural incorrectos.
Contenido cruzado.
Mismatch servidor-cliente.
Ambigüedad cultural y accesible.
URL declara un idioma y contenido usa otro.
[lang].es no determina COP?Estado global y stores en Next.js separa server state, URL state y client state para evitar providers globales innecesarios.