Ir al contenido
Web

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.

18 de julio de 2026 · 9 min de lectura · Actualizado el 12 de octubre de 2026

Three address bars differing only in a lit first segment, joined by curves into one lit node, which feeds a stack of three identical page panels

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.

Preguntas frecuentes

Servicios relacionados

Artículos relacionados

Studio

Empieza un proyecto.

Escribimos sobre lo que construimos. Cuéntanos qué quieres construir tú.