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 y next/root-params, para entender qué hace una biblioteca por ti
  • Configurar next-intl 4 en Next.js 16 con Cache Components: routing.ts, request.ts, proxy.ts, navigation.ts y el plugin de next.config.ts
  • Escribir mensajes con la sintaxis ICU: variables, plurales, select y texto enriquecido, con claves comprobadas por TypeScript
  • Formatear precios, fechas y listas según el idioma con getFormatter y 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/productos y /en/products) y generar enlaces, redirecciones y sitemap.xml correctos en cada idioma
  • Entender cómo detecta el idioma el proxy: cookie NEXT_LOCALE, Accept-Language e idioma por defecto
  • Prerenderizar cada página una vez por idioma con generateStaticParams, cachear componentes traducidos con 'use cache' y añadir los enlaces hreflang
  • 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, requestLocale y setRequestLocale

💡 Consejo: Todo lo que ves en este módulo se ha compilado con next build y ejecutado con next start en Next.js 16.4.3 con next-intl 4.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 de next-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, en http://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 de lib/api.ts por 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 Intl de 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: es y es-ES no son lo mismo. es es “español” sin región; es-ES es “español de España” y es-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 usa es y en porque no necesita más; si algún día vende en México, añadirá es-MX como 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 de params en 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/products si estás en inglés).
  • Detección del idioma del navegador y cookie para recordarlo.
  • Rutas traducidas, hreflang y sitemap.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

Diagrama de las piezas de next-intl: routing.ts alimenta a proxy.ts, navigation.ts y request.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.
  • pathnames es opcional. Sirve para traducir las rutas: la carpeta del proyecto es productos, 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-params es de 16.3. Si usas Next.js 15: lee el idioma de requestLocale en este fichero y llama a setRequestLocale(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:

  1. getRequestConfig recibe una función que se ejecuta una vez por petición (por dentro, next-intl la envuelve en cache de React, que viste en el módulo 08). Da igual que la página llame veinte veces a getTranslations: el JSON se importa una sola vez.
  2. El parámetro locale solo llega si quien llama lo pasa explícitamente, como getTranslations({ locale: 'en', namespace: 'Boletin' }). Es lo que harás en las Server Actions.
  3. Si no llega, se lee del segmento [locale] con rootParams.locale(). Es la función que genera Next a partir de la carpeta app/[locale].
  4. 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 el proxy, la respuesta es un 404.
  5. messages se carga con un import() con plantilla. El empaquetador ve la plantilla ../messages/${locale}.json y prepara un trozo por cada JSON de la carpeta.
  6. timeZone fija la zona horaria. Sin ella, next-intl usa 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 requestLocale en lugar de rootParams.locale(). En Next.js 16 con Cache Components ese patrón rompe el build, porque requestLocale lee una cabecera HTTP (headers()) que pone el proxy, 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-error

La propia biblioteca marca como obsoleta (@deprecated) la función interna de setRequestLocale y remite a next/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ón experimental.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.ts y puede ejecutarse en el runtime edge. Si usas Next.js 15: guarda el mismo código en middleware.ts; createMiddleware funciona 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:

  1. Si la URL no trae idioma (/, /productos), decide uno y redirige (307).
  2. Si la URL trae idioma pero la ruta está en el idioma equivocado (/en/productos), redirige a la buena (/en/products).
  3. 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:

  • generateStaticParams devuelve [{ 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 de request.ts. No hace falta await params. El layout no recibe params porque no lo necesita.
  • generateMetadata usa getTranslations igual que un componente. title.template hace que cada página solo tenga que dar su título (“Catálogo”) y el navegador muestre “Catálogo | Tienda EMPRESA”.
  • NextIntlClientProvider pasa a los Client Components lo que necesitan (apartado 6). Si no le das messages, manda todos los mensajes; aquí solo se mandan dos grupos.
  • <Suspense> alrededor del selector: el selector usa useParams, y con Cache Components un componente cliente que lee los parámetros en un layout exige un límite de Suspense.

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/boletin y /en/boletin. Son HTML distintos generados en el build.
  • /es/productos es 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/1 y las demás fichas (20 productos × 2 idiomas, la mayoría ocultas en [+40 more paths]) también son estáticas.
  • /es es ◐ 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/products no aparece: es una URL pública que el proxy reescribe.
  • ƒ Proxy (Middleware): el proxy.ts se ejecuta en cada petición.

Recorrido de una petición a /en/products/3 por el proxy, el App Router, getTranslations y request.ts

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, no Productos.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 idiomas zero, two, few, many) son categorías del idioma. Intl.PluralRules decide 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.
  • other es 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.

Catálogo de la tienda EMPRESA en español, con precios como 1499,00 € y fechas como 15 mar 2024

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.

El mismo catálogo en inglés, con precios como €1,499.00 y fechas como Mar 15, 2024

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 recibir etiqueta={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/navigation devuelve la ruta interna sin idioma: en /es/productos/2 es la plantilla /productos/[id], porque usas pathnames.
  • 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. Es replace y no push para no llenar el historial de cambios de idioma.
  • El @ts-expect-error es necesario porque TypeScript no puede saber que pathname y params encajan entre sí. La propia documentación de next-intl usa este patrón.
  • useTransition desactiva 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

Ficha de producto en inglés tras usar el selector de idioma, con la URL /en/products/2

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, busca h1:visible.


7. Navegación y rutas traducidas

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 Link de next/link por costumbre, <Link href="/productos"> irá a /productos sin idioma. El proxy lo 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íba next/link y next/navigation en 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/products se 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/products responde 307 a sí misma. El motivo: el proxy construye la URL de la reescritura con el host localhost, y el servidor, que escucha en 127.0.0.1, la considera de otro origen; en lugar de reescribir por dentro, hace una petición HTTP nueva a localhost/en/productos, que pasa otra vez por el proxy y 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 cabecera Host correcta.


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:

Diagrama de decisión del idioma: cookie NEXT_LOCALE, después Accept-Language y por último defaultLocale

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-GB no está en la lista, pero en sí: next-intl hace una coincidencia por idioma base.
  • fr-FR no 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 etiqueta noindex y 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 generateStaticParams no 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"/>
  • canonical apunta 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-default es la página para quien no habla ninguno de los idiomas listados.
  • Las URLs son relativas. Para que sean absolutas, define metadataBase en el layout (módulo 07).

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.

Formulario del boletín en inglés con el mensaje de éxito traducido devuelto por la Server Action

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-params no está disponible de forma estable. Si usas Next.js 15: usa requestLocale en request.ts y llama a setRequestLocale(locale) al principio de cada layout y página con params, 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 en middleware.ts; createMiddleware(routing) y el matcher no 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.

Detalle del pedido 5001 en español: miércoles, 15 de enero de 2025, estado entregado, total 1802,90 €

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

Detalle del pedido 5001 en inglés: Wednesday, January 15, 2025, status delivered, total €1,802.90

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 con noindex, 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 de Suspense, 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 matcher del proxy excluye las rutas con punto, así que /sitemap.xml se sirve tal cual (comprobado: responde 200).
  • alternates.languages produce las etiquetas <xhtml:link rel="alternate" hreflang="..."> de cada <url>.
  • El tipo Href se saca de los parámetros de getPathname (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):

  1. proxy.ts pasa a llamarse middleware.ts, con el mismo contenido.
  2. i18n/request.ts lee requestLocale en lugar de rootParams.locale().
  3. Cada layout y cada página con params llama a setRequestLocale(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