Módulo 19: Internacionalización
Objetivos del módulo
- Distinguir internacionalizar (i18n) de localizar (l10n), y saber qué cambia de verdad entre idiomas: textos, plurales, números, fechas, rutas y metadatos
- Elegir dónde vive el idioma (prefijo en la URL, dominio o cookie) y por qué la URL es la opción que mejor encaja con el renderizado estático de Next.js
- Montar la internacionalización a mano con un segmento
[locale], diccionarios JSON ynext/root-params, para entender qué hace una biblioteca por ti - Configurar
next-intl4 en Next.js 16 con Cache Components:routing.ts,request.ts,proxy.ts,navigation.tsy el plugin denext.config.ts - Escribir mensajes con la sintaxis ICU: variables, plurales,
selecty texto enriquecido, con claves comprobadas por TypeScript - Formatear precios, fechas y listas según el idioma con
getFormattery formatos con nombre - Traducir componentes cliente sin mandar al navegador todos los textos, y construir un selector de idioma que conserva la página actual
- Traducir las propias rutas (
/es/productosy/en/products) y generar enlaces, redirecciones ysitemap.xmlcorrectos en cada idioma - Entender cómo detecta el idioma el
proxy: cookieNEXT_LOCALE,Accept-Languagee idioma por defecto - Prerenderizar cada página una vez por idioma con
generateStaticParams, cachear componentes traducidos con'use cache'y añadir los enlaceshreflang - Pasar el idioma a las Server Actions, donde no hay URL de página
- Saber qué cambia si sigues en Next.js 15:
middleware.ts,requestLocaleysetRequestLocale
💡 Consejo: Todo lo que ves en este módulo se ha compilado con
next buildy ejecutado connext starten Next.js 16.4.3 connext-intl4.14.9, contra la API EMPRESA. Las alternativas para Next.js 15 se han compilado en Next.js 15.5.28 con la misma versión denext-intl. Las salidas de terminal, las cabeceras HTTP y las capturas son reales. Usa siempre el último parche de Next.js 16.4.
📘 Concepto: En este módulo los datos vienen de la API EMPRESA (
recursos/empresa-api.zip, enhttp://localhost:3000/api), igual que en los módulos 00 a 15, para que puedas probarlo sin la base de datos del módulo 16. Si ya tienes la capa de acceso a datos de Drizzle, cambia las funciones delib/api.tspor las tuyas: nada de la internacionalización depende de dónde salen los productos.
1. Qué es internacionalizar una aplicación
La tienda EMPRESA vende en España, pero el departamento comercial quiere abrirla a clientes de fuera. “Traducir la web” parece una tarea de copiar y pegar textos, y no lo es. Fíjate en lo que cambia entre la versión española y la inglesa de una sola tarjeta de producto:
| Qué | Español (es) | Inglés (en) |
|---|---|---|
| Texto fijo | Ver detalle | See details |
| Precio | 1499,00 € | €1,499.00 |
| Fecha | 15 mar 2024 | Mar 15, 2024 |
| Plural | Queda 1 unidad / Quedan 10 unidades | 1 unit left / 10 units left |
| Lista | Portátil, Ratón y Monitor | Portátil, Ratón, and Monitor |
| URL | /es/productos/2 | /en/products/2 |
Etiqueta <html> | lang="es" | lang="en" |
Todas las salidas de la tabla son reales, sacadas de la aplicación que construirás en este módulo. Fíjate en tres detalles:
- El símbolo del euro va detrás en español y delante en inglés, y los separadores decimales se intercambian.
1499,00 €no lleva punto de miles. No es un error: la norma de la RAE y el CLDR (la base de datos de formatos que usan los navegadores y Node) no separan los miles en números de cuatro cifras en español. Con cinco cifras sí aparece:12.500,00 €.- En la lista inglesa aparece una coma antes de and (la Oxford comma), y en la española no. Eso no lo programas tú: lo sabe la API
Intlde JavaScript.
1.1. Vocabulario
| Término | Significado | Ejemplo |
|---|---|---|
| Internationalization (i18n) | Preparar el código para que admita varios idiomas sin reescribirlo: sacar los textos, no concatenar frases, usar formateadores | Este módulo entero |
| Localization (l10n) | Adaptar la aplicación a un idioma y región concretos: traducir, elegir moneda, formato de fecha | Escribir messages/en.json |
| Locale (configuración regional) | Identificador de idioma y, opcionalmente, región, según la norma BCP 47 | es, es-ES, es-MX, en-GB |
| Mensaje | Un texto traducible identificado por una clave | Productos.verDetalle |
| ICU MessageFormat | Sintaxis estándar para mensajes con variables, plurales y alternativas | {n, plural, one {# línea} other {# líneas}} |
Los números de i18n y l10n cuentan las letras que hay entre la primera y la última: i-nternationalizatio-n tiene 18.
📘 Concepto:
esyes-ESno son lo mismo.eses “español” sin región;es-ESes “español de España” yes-MX“español de México”. La diferencia importa en los formatos (en México el separador decimal es el punto) y a veces en el vocabulario (“ordenador” frente a “computadora”). La tienda EMPRESA usaesyenporque no necesita más; si algún día vende en México, añadiráes-MXcomo un idioma más.
1.2. Dónde guardar el idioma elegido
Antes de escribir una línea hay que decidir cómo sabe el servidor en qué idioma responder. Hay tres opciones:
| Estrategia | Ejemplo | Ventajas | Inconvenientes |
|---|---|---|---|
| Prefijo en la ruta | tienda.es/en/products | Cada idioma tiene su URL: se comparte, se indexa y se puede prerenderizar | URLs un poco más largas |
| Dominio o subdominio | tienda.co.uk/products, en.tienda.es | Muy claro para el usuario y para Google | Necesitas varios dominios y certificados |
| Solo cookie o cabecera | tienda.es/productos con NEXT_LOCALE=en | URLs limpias | La misma URL devuelve dos páginas distintas: no se puede cachear en una CDN ni prerenderizar, y Google solo ve un idioma |
La tercera opción parece la más cómoda y es la que más problemas da. Si la página depende de una cookie, Next.js tiene que generarla en cada petición, porque no sabe de antemano qué idioma toca. Con el idioma en la URL, /es/productos y /en/products son dos páginas distintas, cada una prerenderizable y cacheable por separado. Es lo que hace casi toda la web profesional y lo que usarás en este módulo.
La cookie no desaparece: sirve para recordar la elección del usuario. Si alguien eligió inglés y vuelve a entrar por tienda.es/, se le redirige a /en. Lo verás en el apartado 8.
1.3. Qué no resuelve la internacionalización de la interfaz
Las bibliotecas de i18n traducen los textos que escribes tú: botones, títulos, avisos. Los datos que vienen de la base de datos o de la API son otra cosa. En la tienda EMPRESA el producto se llama “Portátil Gaming 16” y su descripción es “Portátil de 16 pulgadas con gráfica dedicada”, en español, y así seguirá saliendo en la versión inglesa (lo verás en las capturas).
Para traducir datos necesitas guardarlos traducidos. Las dos formas habituales son:
- Columnas por idioma:
nombre_es,nombre_en. Sencillo con dos o tres idiomas, incómodo con más. - Tabla de traducciones:
producto_traduccion(producto_id, locale, nombre, descripcion), y la consulta filtra por el idioma actual. Escala a cualquier número de idiomas.
Es un problema de modelo de datos, no de Next.js. Lo dejas planteado en el ejercicio 10.
2. Internacionalizar a mano, sin bibliotecas
Antes de instalar nada, conviene ver qué ofrece Next.js por sí mismo. La guía oficial propone esta estructura, que es la base de cualquier biblioteca:
app/
[lang]/
layout.tsx ← layout raíz: lleva <html lang={...}>
page.tsx
productos/
page.tsx
diccionarios/
es.json
en.json
diccionarios.ts
proxy.ts ← redirige / a /es o /en
La pieza clave es que el layout raíz está dentro de [lang]. No hay app/layout.tsx. Así el <html> puede llevar el atributo lang correcto, que necesitan los lectores de pantalla, los traductores automáticos y los buscadores.
2.1. Diccionarios y hasLocale
{
"productos": {
"titulo": "Catálogo",
"verDetalle": "Ver detalle"
}
}
// app/[lang]/diccionarios.ts
import 'server-only'
const diccionarios = {
es: () => import('./diccionarios/es.json').then((m) => m.default),
en: () => import('./diccionarios/en.json').then((m) => m.default),
}
export type Idioma = keyof typeof diccionarios
export const hasLocale = (valor: string): valor is Idioma => valor in diccionarios
export const getDiccionario = async (idioma: Idioma) => diccionarios[idioma]()
Cada diccionario se carga con un import() dinámico, así que en el servidor solo se lee el JSON del idioma que se pide. Y como este código solo se ejecuta en Server Components (server-only), los textos no aumentan el JavaScript del navegador: llegan ya escritos dentro del HTML.
hasLocale es una type guard: si devuelve true, TypeScript sabe que valor es 'es' | 'en' y no un string cualquiera. Si alguien escribe /fr/productos, la página llama a notFound().
2.2. Leer el idioma con next/root-params 🆕 16.3
🆕 Desde Next.js 16.3. No existe en Next.js 15 (el código de 15.5 solo contiene un esbozo experimental, ligado a
experimental.rootParams; no lo uses). Si usas Next.js 15: lee el idioma deparamsen cada layout y página (const { lang } = await params) y pásalo como argumento a las funciones que lo necesiten.
En una aplicación real, el idioma se necesita en todas partes: en el layout, en la página, en una función que pide datos, en un componente anidado. Pasarlo de prop en prop es tedioso. Next.js 16.3 añadió el módulo next/root-params, que exporta una función por cada segmento dinámico que hay por encima del layout raíz. Como el layout raíz está en app/[lang]/layout.tsx, el módulo exporta lang:
// app/[lang]/diccionarios.ts
import { lang } from 'next/root-params'
import { notFound } from 'next/navigation'
// ... diccionarios y hasLocale como antes
export async function getDiccionario() {
const idioma = await lang()
if (!hasLocale(idioma)) notFound()
return diccionarios[idioma]()
}
// app/[lang]/productos/page.tsx
import { getDiccionario } from '../diccionarios'
export default async function PaginaProductos() {
const d = await getDiccionario()
return <h1>{d.productos.titulo}</h1>
}
Los nombres de las funciones salen de las carpetas: con app/[locale]/layout.tsx importarías locale. Next genera sus tipos al ejecutar next dev, next build o next typegen, igual que PageProps.
¿Por qué solo los segmentos de encima del layout raíz? Porque son los únicos que valen lo mismo para toda la página. [id] en productos/[id] existe en una rama del árbol y no en otra; [lang] existe siempre. Por eso es seguro leerlo desde cualquier Server Component sin saber en qué página estás.
Tiene tres restricciones que encontrarás en este módulo:
| Dónde | ¿Funciona? |
|---|---|
Server Components y funciones que llaman (layouts, páginas, generateMetadata, generateStaticParams anidados) | Sí |
Dentro de 'use cache' | Sí, y el valor pasa a formar parte de la clave de caché |
| Client Components | No: falla al compilar |
| Server Actions | No: falla al ejecutarse (apartado 11) |
| Route Handlers | No, por ahora (la documentación lo anuncia para una versión futura) |
2.3. Lo que le falta a la solución casera
Con diccionarios y root-params tienes textos traducidos en el servidor. Te faltan muchas cosas que acabarás programando:
- Plurales. “1 unidades” está mal, y las reglas no son iguales en todos los idiomas: el polaco distingue “1”, “2-4” y “5 o más”; el árabe tiene seis formas.
- Variables dentro de frases. Concatenar
'Hola, ' + nombre + '.'rompe en idiomas donde el orden de las palabras es otro. - Formatos de números, monedas y fechas consistentes en toda la aplicación.
- Componentes cliente traducidos, sin mandar al navegador el diccionario entero.
- Enlaces que conservan el idioma (
<Link href="/productos">debe ir a/en/productssi estás en inglés). - Detección del idioma del navegador y cookie para recordarlo.
- Rutas traducidas,
hreflangysitemap.xml.
Para todo eso está next-intl, la biblioteca de internacionalización más usada con el App Router, y la que enlaza la propia documentación de Next.js. La versión de este curso es la 4.14.
3. Instalar y configurar next-intl
npm install next-intl
next-intl 4.14 declara compatibilidad con Next.js 12 a 16 (peerDependencies), así que la misma versión vale para quien siga en Next.js 15. Lo que cambia entre una versión y otra de Next.js es cómo se lee el idioma, y lo verás marcado en cada apartado.
3.1. La estructura de ficheros
Esta es la estructura completa de la tienda EMPRESA en dos idiomas. Parte de un proyecto nuevo de create-next-app del que se han borrado app/layout.tsx y app/page.tsx:
empresa/
├── app/
│ ├── [locale]/
│ │ ├── layout.tsx ← layout raíz: <html lang>, proveedor, menú
│ │ ├── page.tsx ← inicio
│ │ ├── not-found.tsx ← 404 traducida
│ │ ├── [...resto]/page.tsx ← cualquier ruta desconocida → notFound()
│ │ ├── productos/
│ │ │ ├── page.tsx ← catálogo (en caché)
│ │ │ └── [id]/page.tsx ← ficha, prerenderizada por idioma
│ │ ├── pedidos/[id]/page.tsx ← detalle de pedido (dinámico)
│ │ └── boletin/ ← formulario con Server Action
│ ├── globals.css
│ └── sitemap.ts
├── components/
│ └── SelectorIdioma.tsx
├── i18n/
│ ├── routing.ts ← idiomas y rutas traducidas
│ ├── navigation.ts ← Link, redirect, useRouter... con idioma
│ ├── request.ts ← configuración por petición
│ └── formatos.ts ← formatos con nombre
├── lib/
│ └── api.ts ← acceso a la API EMPRESA
├── messages/
│ ├── es.json
│ └── en.json
├── global.d.ts ← tipos de mensajes, idiomas y formatos
├── next.config.ts
└── proxy.ts
Figura 1. routing.ts es la fuente única de verdad. El proxy la usa para detectar y redirigir, navigation.ts para generar enlaces y request.ts para cargar los mensajes. El *plugin de next.config.ts conecta request.ts con la biblioteca.*
3.2. i18n/routing.ts: idiomas y rutas
import { defineRouting } from 'next-intl/routing'
export const routing = defineRouting({
locales: ['es', 'en'],
defaultLocale: 'es',
pathnames: {
'/': '/',
'/productos': { es: '/productos', en: '/products' },
'/productos/[id]': { es: '/productos/[id]', en: '/products/[id]' },
'/pedidos/[id]': { es: '/pedidos/[id]', en: '/orders/[id]' },
'/boletin': { es: '/boletin', en: '/newsletter' },
},
})
locales: los idiomas que admite la aplicación. Cualquier otro valor en la URL es un 404.defaultLocale: el que se usa cuando no se puede detectar ninguno.pathnameses opcional. Sirve para traducir las rutas: la carpeta del proyecto esproductos, pero en inglés la URL será/en/products. Las claves son las rutas internas (los nombres de las carpetas) y los valores, la URL pública en cada idioma. Si no te interesa traducir rutas, quítalo y todas las URLs usarán los nombres de las carpetas.
defineRouting no hace nada en tiempo de ejecución salvo devolver el objeto; su función es conservar los tipos literales. Gracias a ella, routing.locales es readonly ['es', 'en'] y no string[], y TypeScript avisará si escribes '/producto' en un enlace.
Hay más opciones (todas comprobadas en los tipos de RoutingConfig):
| Opción | Valor por defecto | Para qué sirve |
|---|---|---|
localePrefix | 'always' | 'always': todas las URLs llevan prefijo. 'as-needed': el idioma por defecto va sin prefijo (/productos y /en/products). 'never': ninguna lleva prefijo (el idioma va solo en la cookie, con los problemas del apartado 1.2) |
localeDetection | true | Si es false, el proxy no mira la cookie ni Accept-Language: sin prefijo, siempre defaultLocale |
localeCookie | { name: 'NEXT_LOCALE', sameSite: 'lax' } | Nombre y atributos de la cookie, o false para no guardarla |
alternateLinks | true | Si el proxy añade la cabecera HTTP Link con las versiones de la página en otros idiomas |
domains | Un dominio por idioma (tienda.es, tienda.co.uk) |
En este módulo se usa 'always', que es la más predecible: cada URL dice su idioma. Si cambias a 'as-needed', el proxy tendrá que reescribir /productos a /es/productos en cada petición, porque la carpeta sigue siendo [locale].
3.3. i18n/request.ts: la configuración de cada petición 🆕 16.3
🆕 Desde Next.js 16.3. No existe en Next.js 15: la lectura del idioma con
next/root-paramses de 16.3. Si usas Next.js 15: lee el idioma derequestLocaleen este fichero y llama asetRequestLocale(locale)en cada layout y página antes de traducir, como se explica en el apartado 12.
Cada vez que un Server Component llama a una función de next-intl (getTranslations, getFormatter, getLocale…), la biblioteca necesita saber el idioma, los mensajes, la zona horaria y los formatos. Eso lo decide este fichero:
import * as rootParams from 'next/root-params'
import { notFound } from 'next/navigation'
import { hasLocale } from 'next-intl'
import { getRequestConfig } from 'next-intl/server'
import { routing } from './routing'
import { formatos } from './formatos'
export default getRequestConfig(async ({ locale }) => {
// Si quien llama pasa el idioma (una Server Action), se respeta.
// Si no, se lee del segmento [locale] de la URL.
if (!locale) {
const valor = await rootParams.locale()
if (!hasLocale(routing.locales, valor)) notFound()
locale = valor
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: 'Europe/Madrid',
formats: formatos,
}
})
Línea a línea:
getRequestConfigrecibe una función que se ejecuta una vez por petición (por dentro,next-intlla envuelve encachede React, que viste en el módulo 08). Da igual que la página llame veinte veces agetTranslations: el JSON se importa una sola vez.- El parámetro
localesolo llega si quien llama lo pasa explícitamente, comogetTranslations({ locale: 'en', namespace: 'Boletin' }). Es lo que harás en las Server Actions. - Si no llega, se lee del segmento
[locale]conrootParams.locale(). Es la función que genera Next a partir de la carpetaapp/[locale]. hasLocale(routing.locales, valor)comprueba que el valor está en la lista y, además, estrecha el tipo a'es' | 'en'. Si alguien llega con/xx/...saltándose elproxy, la respuesta es un 404.messagesse carga con unimport()con plantilla. El empaquetador ve la plantilla../messages/${locale}.jsony prepara un trozo por cada JSON de la carpeta.timeZonefija la zona horaria. Sin ella,next-intlusa la del servidor; si tu servidor está en UTC (lo normal en la nube) y el navegador en Madrid, las horas no coincidirán. Lo verás en el apartado 5.
⚠️ Importante: El patrón que encontrarás en tutoriales de 2024 y 2025 usa
requestLocaleen lugar derootParams.locale(). En Next.js 16 con Cache Components ese patrón rompe el build, porquerequestLocalelee una cabecera HTTP (headers()) que pone elproxy, y las cabeceras son datos de la petición. Lo he comprobado cambiando solo este fichero:Error: Route "/[locale]/productos/[id]": Next.js encountered uncached or runtime data during prerendering. `fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or `connection()` accessed outside of `<Suspense>` prevents the route from being prerendered, blocking the page load and leading to a slower user experience. ... Error occurred prerendering page "/es/productos/9". Read more: https://nextjs.org/docs/messages/prerender-errorLa propia biblioteca marca como obsoleta (
@deprecated) la función interna desetRequestLocaley remite anext/root-params.
3.4. i18n/formatos.ts y los tipos globales
Los formatos con nombre evitan repetir opciones de Intl por toda la aplicación:
import type { Formats } from 'next-intl'
export const formatos = {
dateTime: {
corta: { day: 'numeric', month: 'short', year: 'numeric' },
larga: { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' },
},
number: {
euros: { style: 'currency', currency: 'EUR' },
},
} satisfies Formats
satisfies Formats comprueba que el objeto tiene la forma correcta sin perder los tipos literales: TypeScript sigue sabiendo que existen corta, larga y euros.
Para que todo esté tipado, declara en global.d.ts qué idiomas, mensajes y formatos tiene tu aplicación. next-intl 4 usa para ello la interfaz AppConfig:
import { routing } from '@/i18n/routing'
import { formatos } from '@/i18n/formatos'
import mensajes from '@/messages/es.json'
declare module 'next-intl' {
interface AppConfig {
Locale: (typeof routing.locales)[number]
Messages: typeof mensajes
Formats: typeof formatos
}
}
Con esto, TypeScript comprueba las claves de los mensajes y los nombres de los formatos. Lo he comprobado con tsc --noEmit sobre un fichero con dos errores a propósito:
tipos-prueba.tsx(6,15): error TS2345: Argument of type '"noExiste"' is not assignable to parameter of type 'NamespacedMessageKeys<{ Metadatos: { titulo: string; ...
tipos-prueba.tsx(12,25): error TS2769: No overload matches this call.
...
Argument of type '"dolares"' is not assignable to parameter of type '"euros"'.
El tipo Locale que exporta next-intl pasa a ser 'es' | 'en' en todo el proyecto, y lo usarás en las Server Actions.
💡 Consejo: Con esta configuración, TypeScript comprueba las claves, pero no los argumentos:
t('stock')sin{ unidades }compila. Si quieres que también se comprueben, el plugin tiene la opciónexperimental.createMessagesDeclaration, que genera una declaración de tipos a partir del JSON. No se usa en este módulo.
3.5. next.config.ts: el plugin
import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
turbopack: {
rules: {
'*.css': {
loaders: ['@tailwindcss/turbopack'],
as: '*.css',
},
},
},
}
// Por defecto busca ./i18n/request.ts
const conNextIntl = createNextIntlPlugin()
export default conNextIntl(nextConfig)
El plugin añade un alias: cuando el código de next-intl importa su configuración interna (next-intl/config), el empaquetador lo resuelve a tu i18n/request.ts. Si lo guardas en otro sitio, pásale la ruta: createNextIntlPlugin('./src/i18n/request.ts'). En este módulo se ha compilado con Turbopack, el empaquetador por defecto de la 16, y con Turbopack también en Next.js 15 (next build --turbopack).
3.6. proxy.ts: detectar y redirigir 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 este fichero se llama
middleware.tsy puede ejecutarse en el runtimeedge. Si usas Next.js 15: guarda el mismo código enmiddleware.ts;createMiddlewarefunciona igual en los dos.
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
export default createMiddleware(routing)
export const config = {
// Todo menos /api, /_next, /_vercel y los ficheros con extensión
matcher: '/((?!api|_next|_vercel|.*\\..*).*)',
}
createMiddleware devuelve una función (request) => NextResponse que el proxy exporta por defecto. Hace tres trabajos:
- Si la URL no trae idioma (
/,/productos), decide uno y redirige (307). - Si la URL trae idioma pero la ruta está en el idioma equivocado (
/en/productos), redirige a la buena (/en/products). - Si la URL es correcta pero traducida (
/en/products), la reescribe por dentro a la carpeta real (/en/productos), sin que el navegador lo note.
El matcher excluye lo que no son páginas: rutas de API, ficheros internos de Next y cualquier cosa con un punto (favicon.ico, sitemap.xml, imágenes). Si lo olvidas, el proxy intentaría redirigir /favicon.ico a /es/favicon.ico. El módulo 13 explica el matcher a fondo.
3.7. i18n/navigation.ts: enlaces que conocen el idioma
import { createNavigation } from 'next-intl/navigation'
import { routing } from './routing'
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing)
Son versiones de las APIs de next/link y next/navigation (módulo 03) que añaden el idioma actual y traducen la ruta. En toda la aplicación importarás Link de @/i18n/navigation, no de next/link. Lo verás en el apartado 7.
3.8. El layout raíz dentro de [locale]
// app/[locale]/layout.tsx
import type { Metadata } from 'next'
import { Suspense } from 'react'
import { NextIntlClientProvider } from 'next-intl'
import { getLocale, getMessages, getTranslations } from 'next-intl/server'
import { routing } from '@/i18n/routing'
import { Link } from '@/i18n/navigation'
import { SelectorIdioma } from '@/components/SelectorIdioma'
import '../globals.css'
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('Metadatos')
return {
title: { default: t('titulo'), template: t('plantilla') },
description: t('descripcion'),
}
}
export default async function LayoutRaiz({ children }: LayoutProps<'/[locale]'>) {
const locale = await getLocale()
const t = await getTranslations('Navegacion')
const mensajes = await getMessages()
return (
<html lang={locale}>
<body className="min-h-screen bg-slate-50 text-slate-800">
{/* Al cliente solo le llegan los textos que usan sus componentes */}
<NextIntlClientProvider
messages={{ SelectorIdioma: mensajes.SelectorIdioma, Boletin: mensajes.Boletin }}
>
<header className="flex items-center gap-6 bg-indigo-900 px-6 py-3 text-white">
<nav className="flex gap-4">
<Link href="/">{t('inicio')}</Link>
<Link href="/productos">{t('productos')}</Link>
<Link href="/boletin">{t('boletin')}</Link>
</nav>
<div className="ml-auto">
<Suspense>
<SelectorIdioma />
</Suspense>
</div>
</header>
<main className="mx-auto max-w-3xl p-6">{children}</main>
</NextIntlClientProvider>
</body>
</html>
)
}
Los puntos importantes:
generateStaticParamsdevuelve[{ locale: 'es' }, { locale: 'en' }]. Con Cache Components es obligatorio: cada parámetro raíz debe tener al menos un valor, o el build falla. Y es lo que permite que Next prerenderice cada página una vez por idioma.getLocale()devuelve el idioma de la configuración derequest.ts. No hace faltaawait params. El layout no recibeparamsporque no lo necesita.generateMetadatausagetTranslationsigual que un componente.title.templatehace que cada página solo tenga que dar su título (“Catálogo”) y el navegador muestre “Catálogo | Tienda EMPRESA”.NextIntlClientProviderpasa a los Client Components lo que necesitan (apartado 6). Si no le dasmessages, manda todos los mensajes; aquí solo se mandan dos grupos.<Suspense>alrededor del selector: el selector usauseParams, y con Cache Components un componente cliente que lee los parámetros en un layout exige un límite deSuspense.
3.9. Lo que produce el build
Con todo lo anterior y las páginas de los apartados siguientes, next build produce esto (salida real, recortada a las rutas):
Route (app) Revalidate Expire
┌ ○ /_not-found
├ /[locale]
│ ├ ◐ /[locale]
│ ├ ◐ /es 1h 1d
│ └ ◐ /en 1h 1d
├ /[locale]/[...resto]
│ ├ ◐ /[locale]/[...resto]
│ ├ ◐ /es/[...resto]
│ └ ◐ /en/[...resto]
├ /[locale]/boletin
│ ├ ◐ /[locale]/boletin
│ ├ ○ /es/boletin
│ └ ○ /en/boletin
├ /[locale]/pedidos/[id]
│ ├ ◐ /[locale]/pedidos/[id]
│ ├ ◐ /es/pedidos/[id]
│ └ ◐ /en/pedidos/[id]
├ /[locale]/productos
│ ├ ◐ /[locale]/productos
│ ├ ○ /es/productos 15m 1d
│ └ ○ /en/productos 15m 1d
├ /[locale]/productos/[id]
│ ├ ◐ /[locale]/productos/[id]
│ ├ ◐ /es/productos/[id]
│ ├ ○ /es/productos/1 1h 1d
│ └ ◐ [+40 more paths]
└ ○ /sitemap.xml 1h 1d
ƒ Proxy (Middleware)
Léelo con calma, porque resume el módulo:
- Cada página aparece dos veces, una por idioma:
/es/boletiny/en/boletin. Son HTML distintos generados en el build. /es/productoses estática (○): el catálogo traducido se generó una vez y se sirve sin ejecutar nada. Caduca a los 15 minutos porque el componente del catálogo usa'use cache'sin perfil (apartado 9)./es/productos/1y las demás fichas (20 productos × 2 idiomas, la mayoría ocultas en[+40 more paths]) también son estáticas./eses◐porque el saludo muestra la fecha de hoy, que es un dato de cada petición (apartado 5.4).- Las rutas internas son
/en/productos, con el nombre de la carpeta./en/productsno aparece: es una URL pública que elproxyreescribe. ƒ Proxy (Middleware): elproxy.tsse ejecuta en cada petición.
Figura 2. Una petición a /en/products/3. El proxy reescribe la URL inglesa a la carpeta real, el App Router extrae locale e id, y la primera llamada a getTranslations ejecuta request.ts, que lee el idioma con next/root-params y carga en.json. Todo esto ocurrió en next build; en producción se sirve el HTML guardado.
4. Mensajes
4.1. Organizar los mensajes en grupos
Los mensajes viven en un JSON por idioma, agrupados por componente o página. A cada grupo se le llama namespace:
{
"Metadatos": {
"titulo": "Tienda EMPRESA",
"plantilla": "%s | Tienda EMPRESA",
"descripcion": "Catálogo de informática, electrónica y oficina"
},
"Navegacion": {
"inicio": "Inicio",
"productos": "Productos",
"boletin": "Boletín"
},
"Inicio": {
"titulo": "Bienvenido a la tienda",
"saludo": "Hola, {nombre}. Hoy es {fecha, date, larga}.",
"resumen": "{total, plural, =0 {No hay productos} one {Hay # producto} other {Hay # productos}} en el catálogo.",
"aviso": "Los precios incluyen <negrita>IVA</negrita>. Consulta las <enlace>condiciones</enlace>."
},
"Productos": {
"titulo": "Catálogo",
"precio": "Precio",
"stock": "{unidades, plural, =0 {Agotado} one {Queda # unidad} other {Quedan # unidades}}",
"alta": "Alta: {fecha, date, corta}",
"verDetalle": "Ver detalle"
},
"Producto": {
"categoria": "Categoría: {categoria}",
"precio": "Precio: {precio, number, euros}",
"volver": "Volver al catálogo",
"noEncontrado": "No existe el producto {id}"
},
"Pedido": {
"titulo": "Pedido {id}",
"cliente": "Cliente: {cliente}",
"estado": "Estado: {estado, select, PENDIENTE {pendiente} ENVIADO {enviado} ENTREGADO {entregado} CANCELADO {cancelado} other {desconocido}}",
"lineas": "{n, plural, one {# línea} other {# líneas}}",
"total": "Total",
"productos": "Productos: {lista}"
},
"Boletin": {
"titulo": "Suscríbete al boletín",
"email": "Correo electrónico",
"enviar": "Suscribirme",
"enviando": "Enviando...",
"ok": "Listo: enviaremos las novedades a {email}.",
"error": "El correo no es válido."
},
"SelectorIdioma": {
"etiqueta": "Idioma",
"idioma": "{locale, select, es {Español} en {English} other {?}}"
},
"NoEncontrado": {
"titulo": "Página no encontrada",
"texto": "La dirección que buscas no existe."
}
}
messages/en.json tiene exactamente las mismas claves con los textos en inglés. Un fragmento:
{
"Productos": {
"titulo": "Catalogue",
"precio": "Price",
"stock": "{unidades, plural, =0 {Sold out} one {# unit left} other {# units left}}",
"alta": "Added: {fecha, date, corta}",
"verDetalle": "See details"
},
"Pedido": {
"estado": "Status: {estado, select, PENDIENTE {pending} ENVIADO {shipped} ENTREGADO {delivered} CANCELADO {cancelled} other {unknown}}",
"lineas": "{n, plural, one {# line} other {# lines}}"
}
}
💡 Consejo: Nombra las claves por lo que significan, no por el texto:
Productos.verDetalle, noProductos.verDetalleDelProducto. Si mañana marketing cambia el texto a “Más información”, la clave sigue sirviendo. Y no reutilices una clave en dos sitios solo porque hoy el texto coincide: “Total” en un pedido y “Total” en un informe pueden necesitar traducciones distintas en otro idioma.
4.2. getTranslations en Server Components
import { getTranslations } from 'next-intl/server'
export default async function PaginaBoletin() {
const t = await getTranslations('Boletin')
return <h1>{t('titulo')}</h1>
}
getTranslations('Boletin') devuelve una función t limitada a ese grupo: t('titulo') busca Boletin.titulo. Sin argumento, getTranslations() da acceso a todo y escribes la clave completa: t('Boletin.titulo').
En un Server Component síncrono también puedes usar el hook useTranslations('Boletin'): next-intl lo permite porque en el servidor no hay estado que mantener. La regla práctica es: en componentes async, getTranslations; en componentes síncronos, useTranslations, sean del servidor o del cliente.
4.3. Variables
"cliente": "Cliente: {cliente}"
t('cliente', { cliente: pedido.cliente }) // "Cliente: Talleres Martín S.L."
Las llaves marcan dónde va cada variable. El traductor puede moverlas de sitio sin tocar el código, que es justo lo que no puedes hacer concatenando.
4.4. Plurales
"stock": "{unidades, plural, =0 {Agotado} one {Queda # unidad} other {Quedan # unidades}}"
La sintaxis es {variable, plural, categoría {texto} ...}:
=0,=1… son valores exactos. Tienen prioridad sobre las categorías.one,other(y en otros idiomaszero,two,few,many) son categorías del idioma.Intl.PluralRulesdecide a cuál pertenece cada número según las reglas del CLDR.#se sustituye por el número, ya formateado según el idioma.otheres obligatorio: es la red de seguridad.
Resultados reales (las dos primeras filas, de la tienda; las de 0 y 1, del mismo mensaje formateado con createTranslator de use-intl, el núcleo de next-intl, porque ningún producto de la API tiene ese stock):
| Stock | es | en |
|---|---|---|
| 25 | Quedan 25 unidades | 25 units left |
| 200 | Quedan 200 unidades | 200 units left |
| 0 | Agotado | Sold out |
| 1 | Queda 1 unidad | 1 unit left |
⚠️ Importante: No resuelvas los plurales con un
if (n === 1)en el código. Funciona en español e inglés y falla en cuanto añades otro idioma: en francés, 0 es singular (“0 article”); en polaco, 2, 3 y 4 usan una forma y 5 otra distinta. Con ICU, cada traductor escribe las categorías de su idioma y el código no cambia.
4.5. select: elegir texto según un valor
Los estados de un pedido llegan de la API como PENDIENTE, ENVIADO, ENTREGADO o CANCELADO. Mostrarlos tal cual queda feo y no se traducen. select asocia cada valor a un texto:
"estado": "Estado: {estado, select, PENDIENTE {pendiente} ENVIADO {enviado} ENTREGADO {entregado} CANCELADO {cancelado} other {desconocido}}"
t('estado', { estado: pedido.estado }) // es: "Estado: entregado" en: "Status: delivered"
Es la forma correcta de traducir enumeraciones, y también sirve para concordancias de género: {genero, select, femenino {Bienvenida} masculino {Bienvenido} other {Te damos la bienvenida}}. El selector de idioma la usa para mostrar el nombre de cada idioma ({locale, select, es {Español} en {English} other {?}}).
4.6. Texto enriquecido con t.rich
“Los precios incluyen IVA. Consulta las condiciones.” Partir la frase en tres claves (antes, negrita, despues) obliga al traductor a traducir trozos sin contexto. t.rich deja la frase entera y marca las partes con etiquetas inventadas:
"aviso": "Los precios incluyen <negrita>IVA</negrita>. Consulta las <enlace>condiciones</enlace>."
<p className="text-sm text-slate-600">
{t.rich('aviso', {
negrita: (trozo) => <strong>{trozo}</strong>,
enlace: (trozo) => (
<a className="text-indigo-700 underline" href="/condiciones">
{trozo}
</a>
),
})}
</p>
Cada etiqueta se convierte en una función que recibe el contenido y devuelve JSX. Las etiquetas del JSON no son HTML: no se inyecta nada, así que no hay riesgo de XSS aunque el texto venga de una herramienta de traducción externa. El HTML real generado:
Los precios incluyen <strong>IVA</strong>. Consulta las <a ...>condiciones</a>.
La API tiene más variantes, todas presentes en los tipos de next-intl 4.14:
| Método | Devuelve | Para qué |
|---|---|---|
t(clave, valores) | string | El caso normal |
t.rich(clave, valores) | ReactNode | Etiquetas que se convierten en componentes |
t.markup(clave, valores) | string | Etiquetas que se convierten en HTML como texto (para dangerouslySetInnerHTML o correos) |
t.raw(clave) | El valor del JSON sin procesar | Leer un array u objeto del JSON |
t.has(clave) | boolean | Saber si existe una clave opcional |
5. Formatos: números, fechas y listas
5.1. Formatos dentro de los mensajes
La forma más cómoda de formatear es dentro del propio mensaje:
"alta": "Alta: {fecha, date, corta}",
"precio": "Precio: {precio, number, euros}"
t('alta', { fecha: new Date(p.fechaAlta) }) // "Alta: 10 ene 2024" / "Added: Jan 10, 2024"
t('precio', { precio: producto.precio }) // "Precio: 499,00 €" / "Price: €499.00"
corta y euros son los nombres definidos en i18n/formatos.ts. ICU también admite formatos predefinidos ({fecha, date, short}, {n, number, percent}), pero con nombres propios controlas exactamente el resultado.
5.2. getFormatter en el código
Cuando el valor formateado no forma parte de una frase, usa el formateador:
import { getFormatter } from 'next-intl/server'
const format = await getFormatter()
format.number(p.precio, 'euros') // "899,00 €" / "€899.00"
format.dateTime(new Date(pedido.fechaPedido), 'larga') // "miércoles, 15 de enero de 2025" / "Wednesday, January 15, 2025"
format.list(['Portátil Gaming 16', 'Ratón inalámbrico', 'Monitor 24 FHD'])
// "Portátil Gaming 16, Ratón inalámbrico y Monitor 24 FHD"
// "Portátil Gaming 16, Ratón inalámbrico, and Monitor 24 FHD"
En Client Components el equivalente es el hook useFormatter().
| Método | Envuelve a | Ejemplo |
|---|---|---|
format.number(valor, formato?) | Intl.NumberFormat | Monedas, porcentajes, unidades |
format.dateTime(fecha, formato?) | Intl.DateTimeFormat | Fechas y horas |
format.dateTimeRange(inicio, fin, formato?) | Intl.DateTimeFormat.formatRange | “10–15 ene 2024” / “Jan 10 – 15, 2024” |
format.relativeTime(fecha, ahora) | Intl.RelativeTimeFormat | “hace 3 días”, “3 days ago” |
format.list(valores) | Intl.ListFormat | “A, B y C” |
Todos aceptan el nombre de un formato de formatos.ts o un objeto de opciones de Intl. Y como el nombre está tipado, format.number(3, 'dolares') no compila.

Figura 3. El catálogo en español. El precio lleva el símbolo detrás, la coma decimal y ningún separador de miles en 1499,00 €; la fecha usa la abreviatura “sept” para septiembre.

Figura 4. La misma página en /en/products. Los textos de la interfaz cambian; los nombres de los productos no, porque vienen de la API (apartado 1.3).
5.3. La zona horaria
Una fecha como 2025-01-15 no es un instante, es un día. Pero new Date('2025-01-15') crea las 00:00 UTC de ese día, que en Nueva York aún es el 14 de enero. Si el servidor formatea con la zona de Madrid y el navegador con la de Nueva York, el mismo componente produce dos textos distintos y React avisa de un error de hidratación.
Por eso request.ts fija timeZone: 'Europe/Madrid'. NextIntlClientProvider la hereda y la pasa al cliente: en el código del payload RSC de la página se ve "timeZone":"Europe/Madrid" junto a los formatos. Servidor y cliente formatean igual, estén donde estén.
5.4. La fecha de hoy con Cache Components
El saludo de la página de inicio dice “Hoy es lunes, 2 de noviembre de 2026”. Esa fecha cambia cada día, así que no puede quedarse congelada en el HTML del build:
import { Suspense } from 'react'
import { connection } from 'next/server'
import { getTranslations } from 'next-intl/server'
import { getProductos } from '@/lib/api'
async function Saludo() {
await connection() // la fecha de hoy es un dato de cada petición
const t = await getTranslations('Inicio')
return <p>{t('saludo', { nombre: 'Ana', fecha: new Date() })}</p>
}
export default async function Inicio() {
const t = await getTranslations('Inicio')
const productos = await getProductos()
return (
<section className="space-y-3">
<h1 className="text-2xl font-bold">{t('titulo')}</h1>
<Suspense fallback={<p>...</p>}>
<Saludo />
</Suspense>
<p>{t('resumen', { total: productos.length })}</p>
<p className="text-sm text-slate-600">
{t.rich('aviso', {
negrita: (trozo) => <strong>{trozo}</strong>,
enlace: (trozo) => (
<a className="text-indigo-700 underline" href="/condiciones">
{trozo}
</a>
),
})}
</p>
</section>
)
}
connection() (módulo 09) le dice a Next que lo que viene después depende de la petición. El resto de la página (título, resumen, aviso) se prerenderiza en el build y el saludo llega por streaming. Por eso /es sale como ◐ en el build. Salida real:
Hola, Ana. Hoy es lunes, 2 de noviembre de 2026.
Hay 20 productos en el catálogo.
Los precios incluyen <strong>IVA</strong>. Consulta las <a ...>condiciones</a>.
Hi, Ana. Today is Monday, November 2, 2026.
There are 20 products in the catalogue.
Prices include <strong>VAT</strong>. Read the <a ...>terms</a>.
Lo mismo vale para format.relativeTime(fecha, ahora): si ahora es new Date(), ese componente depende de la petición. Si te basta con que sea relativo a la hora del build, puedes dejarlo en el armazón estático, pero lo normal es pasar ahora explícitamente y ponerlo detrás de connection().
6. Componentes cliente
6.1. NextIntlClientProvider: qué textos llegan al navegador
Un Client Component traduce con useTranslations, igual que en el servidor. La diferencia es de dónde salen los mensajes: en el navegador no hay request.ts ni ficheros JSON. Los recibe del NextIntlClientProvider que pusiste en el layout.
<NextIntlClientProvider
messages={{ SelectorIdioma: mensajes.SelectorIdioma, Boletin: mensajes.Boletin }}
>
Cuando el proveedor se usa desde un Server Component (como en el layout), next-intl rellena automáticamente lo que no le pases: idioma, zona horaria, formatos y, si omites messages, todos los mensajes. En el payload RSC real de /es se ve exactamente lo que se ha mandado:
"formats":{"dateTime":{"corta":{...},"larga":{...}},"number":{"euros":{...}}},
"locale":"es",
"messages":{"SelectorIdioma":{"etiqueta":"Idioma",...},"Boletin":{"titulo":"Suscríbete al boletín",...}},
"timeZone":"Europe/Madrid"
Solo dos grupos de mensajes. Los textos del catálogo, el pedido o los metadatos no viajan como datos porque ya van escritos en el HTML. En una aplicación pequeña da igual; en una con 3000 claves por idioma, mandar todo son decenas de kilobytes que cada visitante descarga para nada.
💡 Consejo: La mejor forma de reducir los mensajes del cliente es no necesitarlos: traduce en el servidor y pasa el texto ya traducido como prop al Client Component. Un botón que solo muestra “Añadir al carrito” no necesita
useTranslations; puede recibiretiqueta={t('anadir')}.
6.2. Un selector de idioma que conserva la página
'use client'
import { useTransition } from 'react'
import { useParams } from 'next/navigation'
import { useLocale, useTranslations, type Locale } from 'next-intl'
import { usePathname, useRouter } from '@/i18n/navigation'
import { routing } from '@/i18n/routing'
export function SelectorIdioma() {
const t = useTranslations('SelectorIdioma')
const locale = useLocale()
const router = useRouter()
const pathname = usePathname()
const params = useParams()
const [pendiente, startTransition] = useTransition()
function cambiar(nuevo: Locale) {
startTransition(() => {
router.replace(
// @ts-expect-error -- pathname y params siempre son de la ruta actual
{ pathname, params },
{ locale: nuevo },
)
})
}
return (
<label className="flex items-center gap-2 text-sm">
{t('etiqueta')}
<select
className="rounded bg-indigo-800 px-2 py-1"
value={locale}
disabled={pendiente}
onChange={(e) => cambiar(e.target.value as Locale)}
>
{routing.locales.map((l) => (
<option key={l} value={l}>
{t('idioma', { locale: l })}
</option>
))}
</select>
</label>
)
}
Cómo funciona:
usePathname()de@/i18n/navigationdevuelve la ruta interna sin idioma: en/es/productos/2es la plantilla/productos/[id], porque usaspathnames.useParams()de Next devuelve{ locale: 'es', id: '2' }.router.replace({ pathname, params }, { locale: 'en' })construye la URL en el otro idioma, traduciendo la ruta:/en/products/2. Esreplacey nopushpara no llenar el historial de cambios de idioma.- El
@ts-expect-errores necesario porque TypeScript no puede saber quepathnameyparamsencajan entre sí. La propia documentación denext-intlusa este patrón. useTransitiondesactiva el<select>mientras se carga la página en el otro idioma.
Además, el Link y el router de next-intl guardan la cookie NEXT_LOCALE al cambiar de idioma. Comprobado con Playwright desde /es/productos/2:
tras cambiar idioma: http://127.0.0.1:5519/en/products/2 Portátil Gaming 16 en
cookie: NEXT_LOCALE=en

Figura 5. La ficha del producto 2 después de elegir “English” en el selector. La URL pasó de /es/productos/2 a /en/products/2, <html lang> es en y la interfaz cambió; el nombre y la descripción vienen de la API y siguen en español.
📘 Concepto: Al probar el selector con Playwright,
page.locator('h1')encontró dos<h1>: el de la ficha en inglés, visible, y el de la ficha en español, oculta. Con Cache Components, Next 16 conserva las páginas que abandonas dentro de<Activity>en lugar de desmontarlas (módulo 03). En las pruebas, buscah1:visible.
7. Navegación y rutas traducidas
7.1. Link, redirect y getPathname
import { Link } from '@/i18n/navigation'
<Link href="/productos">{t('productos')}</Link>
<Link href={{ pathname: '/productos/[id]', params: { id: p.productoId } }}>
{t('verDetalle')}
</Link>
href es la ruta interna (la de las carpetas). El Link añade el idioma actual y traduce la ruta. En el HTML real de las dos versiones:
/es/productos → href="/es/productos/1" href="/es/productos/2" ...
/en/products → href="/en/products/1" href="/en/products/2" ...
Para rutas con parámetros, href es un objeto con pathname y params. TypeScript solo acepta rutas declaradas en pathnames, y exige params cuando la ruta los tiene.
Para cambiar de idioma en un enlace concreto, usa la prop locale: <Link href="/" locale="en">English</Link>.
redirect funciona como el de next/navigation, pero con el idioma: redirect({ href: '/productos', locale }). getPathname({ locale, href }) devuelve la URL como texto, sin redirigir; la usarás para metadatos y sitemap.xml (apartado 10).
⚠️ Importante: Si importas
Linkdenext/linkpor costumbre,<Link href="/productos">irá a/productossin idioma. Elproxylo arreglará con una redirección (un viaje de ida y vuelta extra), y si estabas en inglés y no hay cookie, puede llevarte al español. Una regla de ESLint (no-restricted-imports) que prohíbanext/linkynext/navigationen el proyecto evita el despiste.
7.2. Qué hace el proxy con las rutas traducidas
Comprobado con curl contra next start:
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:5519/en/productos
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:5519/es/products
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://127.0.0.1:5519/en/products
307 http://127.0.0.1:5519/en/products
307 http://127.0.0.1:5519/es/productos
200
/en/productos(carpeta española con prefijo inglés) redirige a la URL inglesa correcta./es/products(URL inglesa con prefijo español) redirige a la española./en/productsse sirve directamente. Las cabeceras de esa respuesta muestran la reescritura interna:
HTTP/1.1 200 OK
link: <http://127.0.0.1:5519/es/productos>; rel="alternate"; hreflang="es", <http://127.0.0.1:5519/en/products>; rel="alternate"; hreflang="en", <http://127.0.0.1:5519/productos>; rel="alternate"; hreflang="x-default"
set-cookie: NEXT_LOCALE=en; Path=/; SameSite=lax
x-middleware-rewrite: /en/productos
x-middleware-rewrite: /en/productos es la carpeta real que sirvió la respuesta. La cabecera link anuncia a los buscadores las versiones en otros idiomas (apartado 10).
⚠️ Importante: Comprobado en Next.js 16.4.3: si arrancas el servidor con
next start -H 127.0.0.1(o--hostname), todas las rutas traducidas entran en un bucle de redirecciones./en/productsresponde 307 a sí misma. El motivo: elproxyconstruye la URL de la reescritura con el hostlocalhost, y el servidor, que escucha en127.0.0.1, la considera de otro origen; en lugar de reescribir por dentro, hace una petición HTTP nueva alocalhost/en/productos, que pasa otra vez por elproxyy acaba redirigida a/en/products. Se ve en las cabeceras:x-middleware-rewrite: http://localhost:5519/en/productos(absoluta) en lugar de/en/productos(relativa). Arranca sin-H, o detrás de un proxy inverso que pase la cabeceraHostcorrecta.
8. Cómo se detecta el idioma
Cuando la URL no trae idioma (alguien escribe tienda.es/ o sigue un enlace antiguo a /productos), el proxy tiene que elegir uno. Este es el orden:
Figura 6. Sin prefijo en la URL, manda la cookie; si no hay, la cabecera Accept-Language del navegador; si nada encaja, el idioma por defecto. Con prefijo, la URL gana siempre.
Comprobado con curl:
# Navegador en inglés británico, sin cookie
curl -s -D - -o /dev/null -H 'Accept-Language: en-GB,en;q=0.9' http://127.0.0.1:5519/
# Navegador en francés, idioma que la tienda no tiene
curl -s -D - -o /dev/null -H 'Accept-Language: fr-FR' http://127.0.0.1:5519/productos
# Navegador en español, pero el usuario eligió inglés antes
curl -s -D - -o /dev/null -H 'Cookie: NEXT_LOCALE=en' -H 'Accept-Language: es' http://127.0.0.1:5519/productos
HTTP/1.1 307 Temporary Redirect
location: /en
HTTP/1.1 307 Temporary Redirect
location: /es/productos
HTTP/1.1 307 Temporary Redirect
location: /en/products
en-GBno está en la lista, peroensí:next-intlhace una coincidencia por idioma base.fr-FRno encaja con nada: idioma por defecto.- La cookie gana a
Accept-Language, y la redirección ya va a la ruta traducida (/en/products, no/en/productos).
Con Playwright y un navegador configurado en en-GB, la raíz acaba en inglés:
raiz con en-GB: http://127.0.0.1:5519/en
Fíjate en que la redirección 307 no guarda la cookie. La guarda la respuesta siguiente, la de /en, que ya trae prefijo (lo viste en la cabecera set-cookie del apartado 7.2).
8.1. ¿Y si alguien escribe un idioma que no existe?
/fr/productos → 307 http://127.0.0.1:5519/es/fr/productos → 404
Para el proxy, fr no es un idioma: es el primer trozo de una ruta sin prefijo. Detecta el español y redirige a /es/fr/productos, que no existe. Esa ruta la recoge el catch-all app/[locale]/[...resto]/page.tsx:
import { notFound } from 'next/navigation'
export default function RutaDesconocida() {
notFound()
}
Y se muestra la página 404 traducida de app/[locale]/not-found.tsx:
import { getTranslations } from 'next-intl/server'
export default async function NoEncontrado() {
const t = await getTranslations('NoEncontrado')
return (
<section>
<h1 className="text-2xl font-bold">{t('titulo')}</h1>
<p>{t('texto')}</p>
</section>
)
}
¿Por qué hace falta el catch-all? not-found.tsx solo se muestra cuando algo llama a notFound() dentro de su segmento. Una URL como /en/nothing-here no coincide con ninguna página, y sin el catch-all Next mostraría su 404 genérico, en inglés y sin tu layout. Comprobado:
/es/no-existe 404
/en/nothing-here 404
404 (Playwright): Page not found en
📘 Concepto: Si miras el HTML de esa respuesta 404 con
curl, verás un documento casi vacío (<html id="__next_error__">, la etiquetanoindexy un script que pide el contenido). Con Cache Components, Next 16.4 entrega el armazón de error y el navegador carga la página 404 traducida con JavaScript, como se ve en la línea de Playwright. El código de estado es un 404 real y los buscadores no la indexan, que es lo que importa.
9. Caché y renderizado estático por idioma
9.1. generateStaticParams en dos niveles
El layout genera los idiomas; cada página dinámica genera sus propios parámetros. Next combina los dos niveles:
// app/[locale]/productos/[id]/page.tsx
export async function generateStaticParams() {
const productos = await getProductos()
return productos.map((p) => ({ id: String(p.productoId) }))
}
Esta función se ejecuta una vez por cada idioma del layout: 20 productos × 2 idiomas = 40 fichas en el build, las del [+40 more paths] de la salida. Si los productos fueran distintos según el idioma, podrías leerlo dentro con await locale() de next/root-params, que funciona en un generateStaticParams anidado.
⚠️ Importante: Recuerda del módulo 04 que con Cache Components
generateStaticParamsno puede devolver un array vacío. Si un idioma aún no tiene productos, devuelve al menos uno o no lo filtres aquí.
La página de la ficha:
export default async function PaginaProducto({ params }: PageProps<'/[locale]/productos/[id]'>) {
const { id } = await params
const producto = await getProducto(Number(id))
if (!producto) notFound()
const t = await getTranslations('Producto')
return (
<article className="space-y-2">
<h1 className="text-2xl font-bold">{producto.nombre}</h1>
<p>{producto.descripcion}</p>
<p>{t('categoria', { categoria: producto.categoria })}</p>
<p className="text-lg text-indigo-800">{t('precio', { precio: producto.precio })}</p>
<Link className="text-indigo-700 underline" href="/productos">
{t('volver')}
</Link>
</article>
)
}
Aquí await params arriba de la página es seguro porque todos los valores posibles salieron en generateStaticParams. HTML real de /en/products/3:
<html lang="en"
<title>Portátil Básico 15 | EMPRESA Store</title>
Category: Portátiles
Price: €499.00
9.2. 'use cache' en un componente traducido
El catálogo es un componente con 'use cache' que traduce por dentro:
async function Catalogo() {
'use cache'
const t = await getTranslations('Productos')
const format = await getFormatter()
const productos = (await getProductos()).slice(0, 6)
return (
<ul className="grid gap-3 sm:grid-cols-2">
{productos.map((p) => (
<li key={p.productoId} className="rounded-lg border border-slate-200 bg-white p-4">
<h2 className="font-semibold">{p.nombre}</h2>
<p className="text-lg text-indigo-800">{format.number(p.precio, 'euros')}</p>
<p className="text-sm">{t('stock', { unidades: p.stock })}</p>
<p className="text-xs text-slate-500">{t('alta', { fecha: new Date(p.fechaAlta) })}</p>
<Link
className="text-sm text-indigo-700 underline"
href={{ pathname: '/productos/[id]', params: { id: p.productoId } }}
>
{t('verDetalle')}
</Link>
</li>
))}
</ul>
)
}
Una función con 'use cache' no recibe argumentos aquí, así que cabe preguntarse: ¿no guardará la misma entrada para los dos idiomas? No, y es la ventaja de next/root-params. getTranslations llama a request.ts, que llama a rootParams.locale(), y Next registra que la función cacheada ha leído el parámetro raíz locale. Ese valor pasa a formar parte de la clave de caché. Resultado: dos entradas, una en español y otra en inglés, como muestran las figuras 3 y 4.
Con el patrón antiguo (requestLocale, que lee una cabecera) esto no sería posible: no se puede leer headers() dentro de 'use cache'.
El otro modo de hacerlo, válido también en Next.js 15 con unstable_cache o con fetch, es pasar el idioma como argumento: la caché incluye los argumentos en la clave.
async function Catalogo({ locale }: { locale: Locale }) {
'use cache'
const t = await getTranslations({ locale, namespace: 'Productos' })
// ...
}
9.3. Datos que dependen del idioma
Si tu API o tu base de datos devuelven datos traducidos (/api/productos?lang=en), cachea por idioma de la misma forma: lee locale() dentro de la función cacheada o pásalo como argumento. Lo que no debes hacer es leer una cookie o Accept-Language en el servidor para decidir el idioma de los datos: la página dejaría de ser prerenderizable y la caché mezclaría idiomas.
10. Metadatos, hreflang y sitemap.xml
Para Google, /es/productos/3 y /en/products/3 son dos páginas distintas con contenido parecido. Si no le dices que son traducciones una de otra, puede tratarlas como contenido duplicado o mostrar la española a un usuario inglés. Para eso están los enlaces hreflang.
10.1. generateMetadata con alternativas
export async function generateMetadata({
params,
}: PageProps<'/[locale]/productos/[id]'>): Promise<Metadata> {
const { id } = await params
const producto = await getProducto(Number(id))
if (!producto) return {}
const locale = await getLocale()
const ruta = (l: (typeof routing.locales)[number]) =>
getPathname({ locale: l, href: { pathname: '/productos/[id]', params: { id } } })
return {
title: producto.nombre,
description: producto.descripcion,
alternates: {
canonical: ruta(locale),
languages: {
es: ruta('es'),
en: ruta('en'),
'x-default': ruta(routing.defaultLocale),
},
},
}
}
HTML real del <head> de /en/products/3:
<title>Portátil Básico 15 | EMPRESA Store</title>
<link rel="canonical" href="/en/products/3"/>
<link rel="alternate" hrefLang="es" href="/es/productos/3"/>
<link rel="alternate" hrefLang="en" href="/en/products/3"/>
<link rel="alternate" hrefLang="x-default" href="/es/productos/3"/>
canonicalapunta a la propia página, en su idioma. Un error frecuente es apuntar siempre a la versión española, y entonces Google descarta la inglesa.x-defaultes la página para quien no habla ninguno de los idiomas listados.- Las URLs son relativas. Para que sean absolutas, define
metadataBaseen el layout (módulo 07).
10.2. La cabecera Link del proxy
El proxy ya añade la misma información como cabecera HTTP (alternateLinks: true por defecto), como viste en el apartado 7.2. Los buscadores aceptan las dos formas. Si generas los enlaces en generateMetadata, puedes desactivar la cabecera con alternateLinks: false para no duplicar.
10.3. sitemap.xml con alternativas
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getPathname } from '@/i18n/navigation'
import { routing } from '@/i18n/routing'
import { getProductos } from '@/lib/api'
const BASE = 'https://tienda.empresa.es'
type Href = Parameters<typeof getPathname>[0]['href']
function entrada(href: Href): MetadataRoute.Sitemap[number] {
const url = (locale: (typeof routing.locales)[number]) =>
BASE + getPathname({ locale, href })
return {
url: url(routing.defaultLocale),
alternates: {
languages: Object.fromEntries(routing.locales.map((l) => [l, url(l)])),
},
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const productos = await getProductos()
return [
entrada('/'),
entrada('/productos'),
...productos.slice(0, 2).map((p) =>
entrada({ pathname: '/productos/[id]', params: { id: p.productoId } }),
),
]
}
app/sitemap.ts está fuera de [locale]: hay un solo sitemap.xml para toda la web. El matcher del proxy excluye las rutas con punto, así que /sitemap.xml no se redirige. Salida real (recortada):
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://tienda.empresa.es/es/productos</loc>
<xhtml:link rel="alternate" hreflang="es" href="https://tienda.empresa.es/es/productos" />
<xhtml:link rel="alternate" hreflang="en" href="https://tienda.empresa.es/en/products" />
</url>
<url>
<loc>https://tienda.empresa.es/es/productos/1</loc>
<xhtml:link rel="alternate" hreflang="es" href="https://tienda.empresa.es/es/productos/1" />
<xhtml:link rel="alternate" hreflang="en" href="https://tienda.empresa.es/en/products/1" />
</url>
11. Server Actions y Route Handlers
11.1. El idioma en una Server Action
Una Server Action no pertenece a ninguna página: es una petición POST a una función. No hay segmento [locale] del que leer y, como viste en la tabla del apartado 2.2, next/root-params no funciona dentro. Si una acción llama a getTranslations('Boletin') sin más, request.ts intenta leer rootParams.locale() y falla. Comprobado en next dev (y la acción responde 500):
⨯ Error: `import('next/root-params').locale()` was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route.
> 12 | const valor = await rootParams.locale()
La solución es pasar el idioma como argumento. Por eso request.ts respeta el locale explícito:
// app/[locale]/boletin/acciones.ts
'use server'
import type { Locale } from 'next-intl'
import { getTranslations } from 'next-intl/server'
export type EstadoBoletin = { ok: boolean; mensaje: string } | null
export async function suscribir(
locale: Locale,
_anterior: EstadoBoletin,
datos: FormData,
): Promise<EstadoBoletin> {
// En una Server Action no hay segmento [locale]: el idioma llega como argumento
const t = await getTranslations({ locale, namespace: 'Boletin' })
const email = String(datos.get('email') ?? '')
if (!email.includes('@')) return { ok: false, mensaje: t('error') }
return { ok: true, mensaje: t('ok', { email }) }
}
Y el formulario fija el primer argumento con bind (módulo 10):
// app/[locale]/boletin/FormularioBoletin.tsx
'use client'
import { useActionState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { suscribir } from './acciones'
export function FormularioBoletin() {
const t = useTranslations('Boletin')
const locale = useLocale()
const [estado, accion, pendiente] = useActionState(suscribir.bind(null, locale), null)
return (
<form action={accion} className="space-y-3">
<label className="block">
{t('email')}
<input name="email" className="mt-1 block w-full rounded border border-slate-300 px-2 py-1" />
</label>
<button
className="rounded bg-indigo-700 px-4 py-2 text-white disabled:opacity-60"
disabled={pendiente}
>
{pendiente ? t('enviando') : t('enviar')}
</button>
{estado && (
<p className={estado.ok ? 'text-green-700' : 'text-red-700'}>{estado.mensaje}</p>
)}
</form>
)
}
Resultado real en /en/newsletter, primero con un correo válido y después con sin-arroba:
boletin: Done: we will send the news to ana.garcia@empresa.es.
boletin error: The email is not valid.

Figura 7. El mensaje de éxito lo generó la Server Action en inglés, porque recibió locale = 'en' del formulario.
⚠️ Importante: El idioma que llega a la acción lo manda el navegador, así que es un dato no fiable. Para elegir textos no importa (como mucho, alguien recibe el mensaje en otro idioma), pero si lo usas para algo más (guardar la preferencia, elegir una plantilla de correo), valídalo con
hasLocale(routing.locales, locale)antes de usarlo.
11.2. Route Handlers
next/root-params tampoco funciona todavía en Route Handlers. Si un route.ts necesita traducir (un correo, un PDF, una respuesta JSON con mensajes), toma el idioma de la ruta (app/api/[locale]/...), de un parámetro de la consulta o de la cookie, valídalo, y pásalo explícitamente: getTranslations({ locale, namespace: 'Correo' }).
12. Si sigues en Next.js 15
Todo lo de los apartados 4 a 10 (mensajes, formatos, navegación, rutas traducidas, hreflang) funciona igual en Next.js 15 con next-intl 4.14. Lo que cambia es la forma de leer el idioma y el modelo de caché.
12.1. request.ts con requestLocale 🆕 16.3
🆕 Desde Next.js 16.3. No existe en Next.js 15:
next/root-paramsno está disponible de forma estable. Si usas Next.js 15: usarequestLocaleenrequest.tsy llama asetRequestLocale(locale)al principio de cada layout y página conparams, como en el código de este apartado.
// i18n/request.ts (Next.js 15)
import { hasLocale } from 'next-intl'
import { getRequestConfig } from 'next-intl/server'
import { routing } from './routing'
export default getRequestConfig(async ({ requestLocale }) => {
// En Next.js 15 el idioma llega de setRequestLocale o de la cabecera del middleware
const pedido = await requestLocale
const locale = hasLocale(routing.locales, pedido) ? pedido : routing.defaultLocale
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
timeZone: 'Europe/Madrid',
}
})
requestLocale es una promesa que se resuelve así: si en esta petición alguien llamó a setRequestLocale, devuelve ese valor; si no, lee la cabecera interna que el middleware añade a la petición. Leer una cabecera convierte la página en dinámica, y setRequestLocale existe para evitarlo:
// app/[locale]/layout.tsx (Next.js 15)
import { notFound } from 'next/navigation'
import { hasLocale, NextIntlClientProvider } from 'next-intl'
import { setRequestLocale } from 'next-intl/server'
import { routing } from '@/i18n/routing'
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }))
}
export default async function LayoutRaiz({ children, params }: LayoutProps<'/[locale]'>) {
const { locale } = await params
if (!hasLocale(routing.locales, locale)) notFound()
// Next.js 15: sin esto, next-intl lee la cabecera del middleware y la ruta pasa a dinámica
setRequestLocale(locale)
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
)
}
// app/[locale]/productos/page.tsx (Next.js 15)
import { getTranslations, setRequestLocale } from 'next-intl/server'
import type { Locale } from 'next-intl'
const API = process.env.API_URL ?? 'http://localhost:3000/api'
export default async function PaginaProductos({ params }: PageProps<'/[locale]/productos'>) {
const { locale } = await params
setRequestLocale(locale as Locale) // en cada página y layout, antes de traducir
const t = await getTranslations('Productos')
const res = await fetch(`${API}/productos?tamano=100`, { next: { revalidate: 3600 } })
const { total } = (await res.json()) as { total: number }
return (
<main>
<h1>{t('titulo')}</h1>
<p>{t('resumen', { total })}</p>
</main>
)
}
setRequestLocale guarda el idioma en una caché de React que dura lo que dura la petición. Hay que llamarlo en cada layout y en cada página, porque Next puede renderizar un layout y una página por separado (por ejemplo, al navegar solo se renderiza la página nueva). La caché de datos es la de Next.js 15: fetch con next.revalidate (módulo 09).
Para ver qué pasa si se te olvida, el proyecto de prueba en Next.js 15.5.28 tiene además una página sin-set que traduce sin llamar a setRequestLocale:
// app/[locale]/sin-set/page.tsx (Next.js 15)
import { getTranslations } from 'next-intl/server'
// Sin setRequestLocale: next-intl lee el idioma de la cabecera del middleware
export default async function SinSet() {
const t = await getTranslations('Productos')
return <h1>{t('titulo')}</h1>
}
Salida real de next build --turbopack:
Route (app) Size First Load JS Revalidate Expire
┌ ○ /_not-found 0 B 114 kB
├ ● /[locale] 0 B 124 kB
├ ├ /es
├ └ /en
├ ● /[locale]/productos 0 B 124 kB 1h 1y
├ ├ /es/productos 1h 1y
├ └ /en/productos 1h 1y
└ ● /[locale]/sin-set 0 B 124 kB
├ /es/sin-set
└ /en/sin-set
+ First Load JS shared by all 114 kB
ƒ Middleware 51.1 kB
El informe engaña: dice que /en/sin-set es ● (SSG), igual que /en/productos. Pero en .next/server/app/en/ solo hay productos.html; sin-set no se generó. Al pedirlas con next start:
== /en/sin-set
HTTP/1.1 200 OK
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
<h1>Catalogue</h1>
== /en/products
HTTP/1.1 200 OK
x-nextjs-cache: HIT
x-nextjs-prerender: 1
Cache-Control: s-maxage=3600, stale-while-revalidate=31532400
<h1>Catalogue</h1>
Las dos páginas se ven bien, pero sin-set se genera en cada petición (no-store): next-intl leyó la cabecera del middleware, la página pasó a dinámica en silencio y el build no lo avisó. Next.js 15 no falla, a diferencia de Next.js 16 con Cache Components (apartado 3.3), que rompe el build y te obliga a arreglarlo. Para detectarlo en 15, mira la cabecera Cache-Control o la carpeta .next/server/app.
12.2. middleware.ts en lugar de proxy.ts 🔄 16.0
🔄 Cambia en Next.js 16.0. En Next.js 15 el fichero se llama
middleware.ts. Si usas Next.js 15: pon el mismo contenido del apartado 3.6 enmiddleware.ts;createMiddleware(routing)y elmatcherno cambian.
12.3. Resumen de diferencias
| Pieza | Next.js 16.4 | Next.js 15.5 |
|---|---|---|
Leer el idioma en request.ts | await rootParams.locale() | await requestLocale |
| En cada layout y página | Nada | const { locale } = await params; setRequestLocale(locale) |
| Fichero de detección | proxy.ts | middleware.ts |
| Componente traducido en caché | 'use cache' sin argumentos (la clave incluye locale) | fetch con next.revalidate o unstable_cache con el idioma como argumento |
| Server Actions | getTranslations({ locale, namespace }) | Igual |
generateStaticParams en el layout | Obligatorio con Cache Components | Recomendado (sin él, las páginas se generan en la primera visita) |
13. Caso práctico: el pedido de la tienda EMPRESA en dos idiomas
La página de detalle de un pedido reúne casi todo lo del módulo: variables, select, plurales, fechas largas, listas y moneda. Además es dinámica: los pedidos cambian, así que no se prerenderizan, y el detalle va dentro de <Suspense> recibiendo la promesa de params (módulo 08).
// lib/api.ts (fragmento)
export type LineaPedido = {
numLinea: number
producto: string
cantidad: number
importe: number
}
export type Pedido = {
pedidoId: number
cliente: string
fechaPedido: string
estado: string
lineas: LineaPedido[]
total: number
}
export async function getPedido(id: number): Promise<Pedido | null> {
const res = await fetch(`${API}/pedidos/${id}`)
if (res.status === 404) return null
if (!res.ok) throw new Error(`API ${res.status}`)
return (await res.json()) as Pedido
}
// app/[locale]/pedidos/[id]/page.tsx
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getFormatter, getTranslations } from 'next-intl/server'
import { getPedido } from '@/lib/api'
async function DetallePedido({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const pedido = await getPedido(Number(id))
if (!pedido) notFound()
const t = await getTranslations('Pedido')
const format = await getFormatter()
return (
<article className="space-y-2">
<h1 className="text-2xl font-bold">{t('titulo', { id: pedido.pedidoId })}</h1>
<p>{t('cliente', { cliente: pedido.cliente })}</p>
<p>{format.dateTime(new Date(pedido.fechaPedido), 'larga')}</p>
<p>{t('estado', { estado: pedido.estado })}</p>
<p>{t('lineas', { n: pedido.lineas.length })}</p>
<p>{t('productos', { lista: format.list(pedido.lineas.map((l) => l.producto)) })}</p>
<p className="text-lg font-semibold">
{t('total')}: {format.number(pedido.total, 'euros')}
</p>
</article>
)
}
export default function PaginaPedido({ params }: PageProps<'/[locale]/pedidos/[id]'>) {
return (
<Suspense fallback={<p>...</p>}>
<DetallePedido params={params} />
</Suspense>
)
}
getTranslations funciona dentro de DetallePedido aunque sea un componente que se ejecuta en cada petición, dentro de Suspense: el idioma sigue saliendo de next/root-params, que no depende de la petición sino de la ruta.

Figura 8. El pedido 5001 en /es/pedidos/5001. El estado ENTREGADO de la API se muestra como “entregado” gracias a select.

Figura 9. El mismo pedido en /en/orders/5001: fecha, estado, plural, lista con *Oxford comma y moneda, todo según el idioma.*
⚠️ Importante:
/es/pedidos/9999(un pedido que no existe) responde 200 connoindex, no 404.notFound()se llama dentro de<Suspense>, cuando el código de estado ya se ha enviado con el armazón de la página. Es el mismo comportamiento que viste en el módulo 04. Si necesitas un 404 real, comprueba que el pedido existe antes del límite deSuspense, aceptando que esa parte bloquea la respuesta.
14. Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
| El build falla con “Next.js encountered uncached or runtime data during prerendering” en todas las páginas traducidas | request.ts usa requestLocale (lee headers()) en Next.js 16 con Cache Components | Usa rootParams.locale() (apartado 3.3) |
Error “import('next/root-params').locale() was used inside a Server Action” | getTranslations('X') sin idioma dentro de una Server Action | Pasa el idioma: getTranslations({ locale, namespace: 'X' }) y request.ts debe respetar el locale recibido |
Bucle de redirecciones 307 en /en/products y demás rutas traducidas | next start -H 127.0.0.1: la reescritura del proxy se trata como externa | Arranca sin -H o con un proxy inverso que pase Host (apartado 7.2) |
| El build falla porque falta un valor de parámetro raíz | No hay generateStaticParams en el layout [locale] con Cache Components | Devuelve routing.locales.map((locale) => ({ locale })) |
| Los enlaces pierden el idioma o hacen una redirección extra | Link importado de next/link | Importa de @/i18n/navigation |
/favicon.ico o /sitemap.xml redirigen a /es/... | El matcher del proxy no excluye los ficheros con extensión | Usa '/((?!api\|_next\|_vercel\|.*\\..*).*)' |
| La 404 de una ruta inexistente sale en inglés y sin tu layout | Falta el catch-all [locale]/[...resto]/page.tsx | Créalo con notFound() (apartado 8.1) |
| Error de hidratación en fechas u horas | Servidor y navegador formatean con zonas horarias distintas | Fija timeZone en request.ts |
El selector de idioma rompe el build o pide un límite de Suspense | Usa useParams dentro de un layout | Envuélvelo en <Suspense> |
Playwright encuentra dos <h1> tras cambiar de idioma | Cache Components conserva la página anterior oculta con <Activity> | Usa h1:visible en las pruebas |
| El navegador descarga todos los textos de la aplicación | <NextIntlClientProvider> sin messages manda todos | Pasa solo los grupos que usan los Client Components |
| “1 unidades” en algún idioma | Plurales resueltos con if en el código | Usa {n, plural, ...} de ICU |
| Los nombres de productos no se traducen | Vienen de la API o de la base de datos | Guarda los datos traducidos (apartado 1.3) |
15. Ejercicios
Ejercicio 1: Stock en plural
En la tarjeta de producto aparece el stock como “Stock: 25”. Cámbialo por un mensaje que diga “Agotado” con 0, “Queda 1 unidad” con 1 y “Quedan N unidades” en el resto, en español e inglés.
Ver solución
En messages/es.json y messages/en.json, dentro de Productos:
"stock": "{unidades, plural, =0 {Agotado} one {Queda # unidad} other {Quedan # unidades}}"
"stock": "{unidades, plural, =0 {Sold out} one {# unit left} other {# units left}}"
En el componente:
<p className="text-sm">{t('stock', { unidades: p.stock })}</p>
=0 tiene prioridad sobre las categorías, así que el 0 muestra “Agotado” en lugar de “Quedan 0 unidades”. Fíjate en que el español cambia también el verbo (“Queda” / “Quedan”): por eso la frase entera va dentro de cada categoría, y no solo la palabra “unidad”. Salida real: “Quedan 25 unidades” y “25 units left”.
Ejercicio 2: Estado del pedido
La API devuelve el estado de un pedido como PENDIENTE, ENVIADO, ENTREGADO o CANCELADO. Muéstralo traducido, en minúsculas, y con un texto de reserva si algún día llega un estado nuevo.
Ver solución
"estado": "Estado: {estado, select, PENDIENTE {pendiente} ENVIADO {enviado} ENTREGADO {entregado} CANCELADO {cancelado} other {desconocido}}"
"estado": "Status: {estado, select, PENDIENTE {pending} ENVIADO {shipped} ENTREGADO {delivered} CANCELADO {cancelled} other {unknown}}"
<p>{t('estado', { estado: pedido.estado })}</p>
other es obligatorio en select y hace de reserva: si la API añade DEVUELTO, la página mostrará “desconocido” en lugar de romperse. Salida real para el pedido 5001: “Estado: entregado” y “Status: delivered”.
Una alternativa sería un objeto en el código ({ PENDIENTE: 'pendiente', ... }), pero entonces la traducción quedaría fuera de los ficheros de mensajes y el traductor no la vería.
Ejercicio 3: Selector de idioma
Crea un componente cliente con un <select> que muestre los idiomas por su nombre (Español, English) y que, al cambiar, lleve a la misma página en el otro idioma, traduciendo la ruta (/es/productos/2 → /en/products/2).
Ver solución
Es el componente SelectorIdioma del apartado 6.2. Las piezas clave:
const pathname = usePathname() // de @/i18n/navigation: '/productos/[id]'
const params = useParams() // de next/navigation: { locale: 'es', id: '2' }
router.replace(
// @ts-expect-error -- pathname y params siempre son de la ruta actual
{ pathname, params },
{ locale: nuevo },
)
En el layout, va dentro de <Suspense> porque usa useParams, y el layout le pasa los mensajes SelectorIdioma en NextIntlClientProvider.
El nombre de cada idioma sale de un select de ICU: "idioma": "{locale, select, es {Español} en {English} other {?}}". Fíjate en que el nombre de cada idioma va en su propio idioma en las dos versiones del JSON: quien no entiende español tiene que poder encontrar “English” en una página española.
Comprobado con Playwright: la URL pasa a http://127.0.0.1:5519/en/products/2, <html lang> a en y se guarda la cookie NEXT_LOCALE=en.
Ejercicio 4: Metadatos con hreflang
Haz que la ficha de cada producto tenga como título el nombre del producto, como descripción su descripción, y enlaces canonical y alternate a sus versiones en los dos idiomas, con las rutas traducidas.
Ver solución
Es el generateMetadata del apartado 10.1. La clave es getPathname de @/i18n/navigation, que construye la URL de cualquier idioma con la ruta traducida:
const ruta = (l: (typeof routing.locales)[number]) =>
getPathname({ locale: l, href: { pathname: '/productos/[id]', params: { id } } })
return {
title: producto.nombre,
description: producto.descripcion,
alternates: {
canonical: ruta(locale),
languages: { es: ruta('es'), en: ruta('en'), 'x-default': ruta(routing.defaultLocale) },
},
}
El título se completa con la plantilla del layout ("%s | EMPRESA Store"). HTML real en /en/products/3: <title>Portátil Básico 15 | EMPRESA Store</title>, <link rel="canonical" href="/en/products/3"/> y un alternate por idioma.
Error típico: escribir las rutas a mano (/${locale}/productos/${id}). Funciona en español y genera /en/productos/3 en inglés, que el proxy redirige: Google recibiría un enlace que no es la página final.
Ejercicio 5: sitemap.xml multilingüe
Genera sitemap.xml con la página de inicio, el catálogo y las fichas de producto. Cada entrada debe listar sus versiones en los dos idiomas.
Ver solución
Es el app/sitemap.ts del apartado 10.3. Puntos importantes:
- Va en
app/sitemap.ts, fuera de[locale], porque es un único fichero para todo el sitio. - El
matcherdelproxyexcluye las rutas con punto, así que/sitemap.xmlse sirve tal cual (comprobado: responde 200). alternates.languagesproduce las etiquetas<xhtml:link rel="alternate" hreflang="...">de cada<url>.- El tipo
Hrefse saca de los parámetros degetPathname(Parameters<typeof getPathname>[0]['href']), así que TypeScript acepta tanto'/'como{ pathname: '/productos/[id]', params: { id } }y rechaza rutas que no existen.
La salida real del build lo muestra como ○ /sitemap.xml 1h 1d: es estático y se regenera cada hora, porque getProductos usa cacheLife('hours').
Ejercicio 6: Boletín traducido con una Server Action
Crea un formulario de suscripción al boletín cuya Server Action valide el correo y devuelva el mensaje de éxito o de error en el idioma de la página.
Ver solución
Es el código del apartado 11.1. La acción recibe el idioma como primer argumento:
export async function suscribir(locale: Locale, _anterior: EstadoBoletin, datos: FormData) {
const t = await getTranslations({ locale, namespace: 'Boletin' })
// ...
}
Y el formulario lo fija con bind:
const locale = useLocale()
const [estado, accion, pendiente] = useActionState(suscribir.bind(null, locale), null)
Para que funcione, request.ts debe usar el locale recibido y no intentar leer rootParams.locale(), que dentro de una Server Action lanza “was used inside a Server Action. This is not supported”. Salida real en /en/newsletter: “Done: we will send the news to ana.garcia@empresa.es.” y “The email is not valid.”.
Fíjate en que Boletin es uno de los dos grupos de mensajes que el layout manda al cliente: el formulario es un Client Component y usa useTranslations('Boletin') para la etiqueta y el botón.
Ejercicio 7: Página de pedido con formatos
Muestra el pedido con su fecha en formato largo (“miércoles, 15 de enero de 2025”), el número de líneas en plural, la lista de productos separada por comas con “y” al final, y el total en euros.
Ver solución
Es la página del apartado 13. Los formatos:
format.dateTime(new Date(pedido.fechaPedido), 'larga')
t('lineas', { n: pedido.lineas.length }) // "{n, plural, one {# línea} other {# líneas}}"
t('productos', { lista: format.list(pedido.lineas.map((l) => l.producto)) })
format.number(pedido.total, 'euros')
Salida real:
es | en | |
|---|---|---|
| Fecha | miércoles, 15 de enero de 2025 | Wednesday, January 15, 2025 |
| Líneas | 3 líneas | 3 lines |
| Lista | Portátil Gaming 16, Ratón inalámbrico y Monitor 24 FHD | Portátil Gaming 16, Ratón inalámbrico, and Monitor 24 FHD |
| Total | 1802,90 € | €1,802.90 |
format.list usa Intl.ListFormat: tú no decides si va coma antes de la conjunción ni cuál es la conjunción. La página es dinámica (◐) y el detalle va en <Suspense> con la promesa de params, porque los pedidos no se prerenderizan.
Ejercicio 8: Llevar la tienda a Next.js 15
Tu empresa sigue en Next.js 15.5 y no puede actualizar este trimestre. Adapta la configuración de next-intl para que el catálogo siga siendo estático.
Ver solución
Tres cambios (apartado 12):
proxy.tspasa a llamarsemiddleware.ts, con el mismo contenido.i18n/request.tsleerequestLocaleen lugar derootParams.locale().- Cada layout y cada página con
paramsllama asetRequestLocale(locale)antes de traducir:
export default async function PaginaProductos({ params }: PageProps<'/[locale]/productos'>) {
const { locale } = await params
setRequestLocale(locale as Locale)
const t = await getTranslations('Productos')
// ...
}
Además, 'use cache' no existe sin Cache Components: la caché de los datos pasa a fetch(url, { next: { revalidate: 3600 } }).
Compilado en Next.js 15.5.28: las páginas con setRequestLocale se prerenderizan por idioma (/en/productos responde con x-nextjs-cache: HIT). La que no lo llama también aparece como ● en el informe del build, pero no se genera su HTML y se sirve con Cache-Control: no-store, como se ve en el apartado 12.1. Es el error más traicionero en proyectos con Next.js 15: olvidar setRequestLocale en una página y no notarlo, porque ni el build ni la página dan ninguna pista visible.
Ejercicio 9 (propuesto): Añadir el francés
Añade fr a la tienda: el idioma en routing.ts, el fichero messages/fr.json, las rutas traducidas (/fr/produits, /fr/commandes) y el nombre en el selector. Comprueba con curl -H 'Accept-Language: fr-FR' que la raíz ya redirige a /fr, y fíjate en cómo formatea Intl los precios en francés (el separador de miles es un espacio especial).
Ejercicio 10 (propuesto): Productos con nombre traducido
Con la base de datos del módulo 16, crea una tabla producto_traduccion(producto_id, locale, nombre, descripcion). Cambia getProductos para que lea el idioma con next/root-params dentro de 'use cache' y devuelva el nombre traducido, o el español si falta la traducción. Comprueba en el build que el catálogo inglés muestra los nombres en inglés.
Ejercicio 11 (propuesto): Español sin prefijo
Cambia localePrefix a 'as-needed' para que las URLs españolas no lleven /es (/productos, /pedidos/5001) y las inglesas sí (/en/products). Comprueba con curl qué hace ahora el proxy con /es/productos y con /productos, y revisa que getPathname, los hreflang y el sitemap.xml generan las URLs nuevas.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Idioma en la URL | app/[locale]/layout.tsx | El layout raíz va dentro de [locale]: cada idioma es una página distinta, prerenderizable y cacheable |
| Parámetro raíz 🆕 16.3 | import { locale } from 'next/root-params' | Lee el idioma desde cualquier Server Component, también dentro de 'use cache'; no en Server Actions ni Route Handlers |
| Rutas | defineRouting({ locales, defaultLocale, pathnames }) | Fuente única de verdad; pathnames traduce las URLs |
| Configuración por petición | getRequestConfig en i18n/request.ts | Idioma, mensajes, timeZone y formatos; respeta el locale explícito |
| Plugin | createNextIntlPlugin() | Conecta request.ts con la biblioteca |
| Detección 🔄 16.0 | createMiddleware(routing) en proxy.ts | Cookie NEXT_LOCALE, después Accept-Language, después defaultLocale; reescribe las rutas traducidas |
| Navegación | createNavigation(routing) | Link, redirect, useRouter, usePathname, getPathname con idioma |
| Traducir en el servidor | getTranslations('Grupo') | t('clave', valores); useTranslations en componentes síncronos |
| Plurales | {n, plural, =0 {...} one {...} other {...}} | Las categorías las decide Intl.PluralRules; nunca if (n === 1) |
| Alternativas | {v, select, A {...} other {...}} | Enumeraciones y concordancias |
| Texto enriquecido | t.rich('clave', { etiqueta: (t) => <b>{t}</b> }) | Frases enteras para el traductor, sin HTML inyectado |
| Formatos | getFormatter(), useFormatter() | number, dateTime, relativeTime, list con formatos con nombre |
| Tipos | interface AppConfig en global.d.ts | Claves de mensajes, idiomas y formatos comprobados por TypeScript |
| Componentes cliente | NextIntlClientProvider | Manda solo los grupos de mensajes que hacen falta |
| Estático por idioma | generateStaticParams en el layout | Obligatorio con Cache Components; multiplica las páginas por idiomas |
| Caché por idioma | 'use cache' + getTranslations | La clave incluye locale al leerse con next/root-params |
| SEO | alternates.languages, sitemap.ts | hreflang, canonical propio y x-default |
| Server Actions | getTranslations({ locale, namespace }) | El idioma llega como argumento con bind |
| Next.js 15 | requestLocale + setRequestLocale(locale) | En cada layout y página; middleware.ts en lugar de proxy.ts |