Web Design and Engineering18 de julio de 20269 min de lectura

i18n en Next.js sin librería: el patrón del App Router

El App Router de Next.js no trae i18n integrado. Servimos tres idiomas con routing nativo y cero dependencias. Este es el patrón que usamos en producción.

a signpost with several arrows

Al final de esta guía tienes un sitio Next.js 16 que sirve tres idiomas sobre routing nativo, sin ningún paquete de i18n entre las dependencias. Inglés en /en, italiano en /it, español en /es, URLs localizadas donde importan y etiquetas hreflang correctas, para que Google muestre a cada mercado su versión. Es el patrón que usamos en producción en un sitio de tres idiomas.

El App Router quitó el routing i18n integrado que el Pages Router traía desde la versión 10 de Next.js. En next.config.js ya no existe la clave i18n. Parece un hueco. Es más bien un lienzo limpio: el routing de idiomas vive ahora en código tuyo y, para un conjunto fijo de idiomas, no hace falta una librería para gestionarlo. Una librería compensa por otro motivo, y lo vemos al final.

Qué necesitas antes de empezar

  • Next.js 16 sobre App Router, React 19.
  • Un conjunto de idiomas decidido y pequeño. Este patrón aguanta de 2 a 6 idiomas. Más allá, una herramienta de gestión de traducciones empieza a compensar.
  • Traducciones que puedas expresar como cadenas clave-valor. Textos de marketing, etiquetas, metadatos SEO: sí. Contratos legales con cláusulas por jurisdicción: esos trátalos como documentos aparte, no como claves de diccionario.
  • Una decisión tomada de entrada: qué idioma es el de por defecto y si lleva prefijo en la URL. Nosotros prefijamos los tres (/en, /it, /es) porque unas URLs simétricas son más fáciles de razonar que un por defecto en la raíz.

Paso 1: pon cada página bajo un segmento app/[lang]

Todo el patrón cuelga de un solo segmento dinámico. Mueve cada ruta pública bajo app/[lang]/. Next.js pasa el parámetro lang a cada layout y página por debajo.

app/
  [lang]/
    layout.tsx      // lee lang, monta el provider
    page.tsx        // home
    blog/
      page.tsx
      [slug]/page.tsx
  layout.tsx        // root: html, fuentes, nada específico de idioma

Mantén los nombres de carpeta en el idioma por defecto (inglés): blog, projects, about. Las URLs traducidas llegan luego, en el Paso 5, con una rewrite: así no acabas con tres copias del mismo árbol de rutas.

Paso 2: detecta el idioma en proxy.ts, el archivo que antes era el middleware

Next.js 16 renombró middleware.ts a proxy.ts y la función exportada de middleware a proxy. La lógica no cambia, el runtime es solo Node, el archivo viejo sigue funcionando pero está obsoleto. En un proyecto existente lanza npx @next/codemod middleware-to-proxy .. Los proyectos nuevos arrancan directamente en proxy.ts.

El proxy hace tres cosas: manda la / pelada al idioma más probable del visitante, deja pasar las peticiones que ya llevan un prefijo válido y se salta los assets estáticos y /api. La detección lee la cabecera Accept-Language.

// proxy.ts
import { NextRequest, NextResponse } from 'next/server'

const locales = ['en', 'it', 'es']
const defaultLocale = 'en'

function pickLocale(req: NextRequest) {
  const header = req.headers.get('accept-language') ?? ''
  const wanted = header.split(',').map(p => p.split(';')[0].trim().slice(0, 2))
  return wanted.find(l => locales.includes(l)) ?? defaultLocale
}

export function proxy(req: NextRequest) {
  const { pathname } = req.nextUrl
  const hasLocale = locales.some(l => pathname === '/' + l || pathname.startsWith('/' + l + '/'))
  if (hasLocale) return NextResponse.next()
  const locale = pickLocale(req)
  return NextResponse.redirect(new URL('/' + locale + pathname, req.url))
}

export const config = { matcher: ['/((?!api|_next|.*\..*).*)'] }

El match a dos letras hecho a mano vale para pocos idiomas. La guía oficial de Next.js tira de Negotiator y @formatjs/intl-localematcher para un match best-fit de verdad, que tiene en cuenta los valores de calidad. Los dos son diminutos. Los necesitas en cuanto gestionas variantes regionales como pt-BR frente a pt-PT, donde el corte ingenuo a dos letras se rompe.

Paso 3: los diccionarios son cadenas, nunca funciones

Es la regla que te evita un fallo en build. Un diccionario es un objeto anidado de cadenas, un archivo por idioma, con la misma forma en los tres.

// dictionaries/en.ts
export const en = {
  nav: { home: 'Inicio', blog: 'Blog', contact: 'Contacto' },
  blog: { readingTime: '{n} min de lectura' },
} as const

export type Dictionary = typeof en
// it.ts y es.ts declaran las mismas claves, o TypeScript protesta

Nunca metas una función en un diccionario. React 19 y Next 16 lanzan "Functions cannot be passed directly to Client Components" en build en cuanto un valor-función cruza la frontera Server-Client. Así que nada de readingTime: (n) => `${n} min`. Guarda el marcador como cadena e interpola donde lo usas: t.blog.readingTime.replace('{n}', String(minutos)). Con Dictionary = typeof en el inglés queda como fuente de verdad: cada clave que añades en inglés y olvidas en italiano es un error de tipo, no un hueco en producción.

Paso 4: lee el diccionario en los Server Components y pásalo a los Client Components

Una función getDictionary asocia un idioma a su objeto. Los Server Components la llaman directamente tras esperar params. En Next 16 params es una promesa.

// dictionaries/index.ts
import { en } from './en'; import { it } from './it'; import { es } from './es'
const dicts = { en, it, es }
export const getDictionary = (lang: string) => dicts[lang] ?? en

// app/[lang]/page.tsx (Server Component)
export default async function Home({ params }: { params: Promise<{ lang: string }> }) {
  const { lang } = await params
  const t = getDictionary(lang)
  return <h1>{t.nav.home}</h1>
}

Un Client Component no puede llamar a una función de servidor asíncrona, así que envuelve el árbol en un context provider que recibe del layout el diccionario ya resuelto. Un hook useDictionary() lo lee en cualquier punto por debajo. Así el diccionario se queda fuera del bundle de cliente, salvo las cadenas que una página concreta pinta de verdad. Si no tienes claro qué componentes deben ir en el cliente, nuestro artículo sobre la arquitectura del sitio explica dónde trazamos esa línea.

Paso 5: da a cada idioma sus propios slugs

Un usuario italiano busca progetti, no projects. Servir /it/projects deja posicionamiento sobre la mesa. En lugar de duplicar el árbol de rutas, mantén un único árbol interno con slugs en inglés y en el proxy reescribe hacia él las URLs localizadas.

// config/routes.ts
export const ROUTE_SLUGS = {
  it: { projects: 'progetti', about: 'chi-siamo' },
  es: { projects: 'proyectos', about: 'nosotros' },
}
// el proxy reescribe /es/proyectos -> interno /es/projects, de forma transparente

El visitante ve /es/proyectos. Next.js pinta el archivo en app/[lang]/projects/. Un solo árbol de rutas, tres superficies de URL. Un helper translatePathname hace lo contrario para tu selector de idioma, así quien está en /es/proyectos aterriza en /it/progetti, no en /it/proyectos.

Paso 6: emite canonical y hreflang desde generateMetadata

Los buscadores necesitan saber que las tres URLs son la misma página en idiomas distintos. Emite los alternate hreflang y un canonical auto-referenciado desde generateMetadata. Si te equivocas aquí, Google trata las páginas italiana y española como duplicados pobres de la inglesa.

export async function generateMetadata({ params }) {
  const { lang } = await params
  return {
    alternates: {
      canonical: `https://example.com/${lang}`,
      languages: { en: '/en', it: '/it', es: '/es', 'x-default': '/en' },
    },
  }
}

El <html lang> de la raíz puede quedarse estático en el por defecto, mientras el hreflang por página lleva la señal de verdad, y a los crawlers les basta. Hacer dinámico el <html lang> significa mover el elemento a [lang]/layout.tsx: un cambio que puedes aplazar hasta que haga falta.

Cómo comprobar que funciona

  • Llama a / con una cabecera Accept-Language: it y comprueba que el redirect aterriza en /it.
  • Abre /it/progetti y comprueba que pinta la página de proyectos en italiano, con la URL intacta en la barra.
  • Mira el código fuente de una página y busca tres etiquetas <link rel="alternate" hreflang> más un canonical.
  • Añade una clave solo a en.ts y lanza tsc. Un error de tipo confirma que los diccionarios están sincronizados.
  • Haz la build de producción. Ningún error "Functions cannot be passed" significa diccionarios limpios.

Fallos frecuentes y cómo resolverlos

El proxy entra en bucle de redirect. Tu matcher captura /_next o archivos estáticos, así que cada asset se redirige y se vuelve a pedir. Aprieta el config.matcher para excluir api, _next y todo lo que tenga extensión de archivo.

La build falla con "Functions cannot be passed directly to Client Components". Un valor del diccionario es una función, o pasas un helper de servidor como prop a un componente de cliente. Convierte las cadenas dinámicas en marcadores e interpola donde las usas.

El selector de idioma manda a los usuarios a un 404. Cambia solo el prefijo del idioma y conserva el slug de origen, así /it/progetti pasa a /es/progetti, que no existe. Haz pasar el cambio por tu mapa de slugs inverso, o usa los alternate hreflang que ya están en el DOM.

Las páginas italianas se posicionan bajo la URL inglesa. hreflang ausente o mal puesto. Comprueba que cada idioma lista todos los alternate, incluido x-default, y que cada canonical apunta a sí mismo, no a la versión inglesa.

Cuándo una librería compensa de verdad

El routing de arriba no necesita ninguna dependencia. El límite honesto es la gramática. En cuanto el texto necesita plurales de verdad, una librería se paga sola. El inglés tiene 2 formas de plural, el árabe tiene 6, y el polaco y el ruso quedan en medio con reglas que un ternario ?: no expresa de forma limpia. Eso es lo que resuelve la sintaxis ICU de mensajes, y es la razón real para tirar de algo como next-intl, que pesa unos 2KB y sabe precompilar los mensajes ICU por debajo de 1KB apoyándose en la API nativa Intl.

Tira de una librería cuando se cumple alguno de estos puntos: necesitas plurales o género ICU, entregas las traducciones a no desarrolladores mediante un sistema de gestión de traducciones, o el número de idiomas sube más allá de un puñado. Quédate en nativo cuando los idiomas son pocos, las cadenas son pares clave-valor planos y el equipo las edita en el repo. Nosotros nos quedamos en nativo. Para el objeto Intl por sí solo, que formatea fechas, números y monedas por idioma, no hace falta ningún paquete. Si estás sopesando la cuestión más amplia de las dependencias, nuestra lectura sobre qué cambia en Next.js 16 y React 19 explica por qué mantenemos el árbol pequeño.

Foto de Ian Ward en Unsplash

Preguntas frecuentes

¿Hace falta una librería de i18n para Next.js en 2026?
No para un conjunto fijo y pequeño de idiomas cuyas traducciones son cadenas clave-valor planas. El App Router te da segmentos de ruta dinámicos, detección de idioma en el proxy y la API nativa Intl, que cubren routing, formato y metadatos sin dependencia. Una librería hace falta cuando el texto necesita plurales o género ICU, cuando editan las traducciones personas no desarrolladoras mediante una herramienta de gestión, o cuando el número de idiomas sube más allá de un puñado. Para dos a seis idiomas editados en el repo, lo nativo es menos código que mantener y nada que actualizar.
¿Qué sustituyó a middleware.ts para el routing i18n en Next.js 16?
Next.js 16 renombró middleware.ts a proxy.ts y la función exportada de middleware a proxy. La lógica i18n es idéntica: detecta el idioma, redirige la raíz pelada, reescribe los slugs localizados. El runtime es solo Node, así que el runtime Edge ya no está disponible para este archivo, y el viejo middleware.ts sigue funcionando pero está obsoleto. Lanza npx @next/codemod middleware-to-proxy . para migrar de forma mecánica un proyecto existente.
¿Cómo gestiono los plurales sin una librería de i18n?
Para casos simples, la API nativa Intl.PluralRules te dice en qué categoría de plural cae un número para un idioma dado, y ramificas sobre eso. Funciona bien para inglés, italiano y español, que tienen dos formas cada uno. Se vuelve doloroso con idiomas de tres a seis formas, como el polaco, el ruso o el árabe, donde las reglas no son obvias y es fácil equivocarse. Ese es el punto en que la sintaxis ICU de mensajes, y una librería que la implementa, deja de ser opcional. Si todos tus idiomas tienen reglas de plural simples, quédate en nativo; si uno tiene reglas complejas, adopta ICU en vez de escribirlas a mano.
¿Puedo añadir un cuarto idioma después sin rehacerlo todo?
Sí, si desde el principio sacaste la lista de idiomas de un solo array de config. Añadir un idioma son tres cosas: añade el código al array locales, añade su archivo de diccionario con las mismas claves que el inglés, y rellena sus entradas en el mapa de slugs localizados. Ningún archivo de ruta cambia, porque cada página ya vive bajo el único segmento app/[lang]. El coste real es la traducción, no el código. Es la ventaja de mantener el inglés como fuente de verdad tipada: en cuanto a un diccionario nuevo le falta una clave, TypeScript la señala antes de publicar.

Artículos relacionados

Studio

Empieza un proyecto.

Un partner único para el producto digital que necesitas construir. Producción más rápida, tecnología moderna, costes reducidos. Un equipo, una factura.