Respuesta rápida
Para añadir internacionalización (i18n) a Next.js 15, usa next-intl con App Router. Instálalo con npm install next-intl, crea una carpeta [locale] en tu directorio app, configura el middleware para la detección de idioma y usa getTranslations() en Server Components o useTranslations() en Client Components. Los Server Components de Next.js 15 eliminan el exceso de traducciones en el lado del cliente renderizándolas en el servidor.
Pasé la mayor parte del mes pasado migrando el sitio de comercio electrónico de un cliente de Next.js 13 Pages Router a Next.js 15 App Router. El sitio necesitaba soportar 12 idiomas en toda Europa y América Latina. Lo que aprendí en el camino es que i18n en Next.js 15 es genuinamente mejor que antes, pero todavía hay muchas formas de dispararte en el pie.
Esta es la guía que desearía que existiera cuando empecé.
Qué cambió realmente en Next.js 15
Seamos honestos: si vienes del Pages Router, el App Router se siente como un marco diferente. ¿La buena noticia? Los Server Components cambian todo sobre cómo manejamos las traducciones.
Aquí está lo que hizo clic para mí:
Con el enfoque antiguo, cada clave de traducción tenía que enviarse al cliente. Tus usuarios japoneses descargaban cadenas en español, alemán y francés que nunca verían. Con Server Components, las traducciones pueden permanecer completamente en el servidor. El cliente solo recibe el HTML renderizado.
| Lo que obtienes | Pages Router | App Router |
|---|---|---|
| Traducciones renderizadas en servidor | Más o menos | Sí, correctamente |
| Streaming | Hacky | Integrado |
| Layouts anidados por locale | Trabajo manual | Simplemente funciona |
| Tamaño del bundle de traducción | Todos los idiomas | Solo idioma actual |
La parte de streaming me sorprendió. Tenía una página con más de 200 claves de traducción, y en lugar de esperar a todo, el shell se renderiza inmediatamente mientras las traducciones se transmiten.
Eligiendo una librería (Tengo opiniones)
He usado las tres opciones principales en producción. Aquí está mi opinión honesta:
next-intl es lo que uso ahora. Fue diseñado específicamente para App Router desde cero. La experiencia de desarrollo es excelente, el soporte de TypeScript es de primera clase y el bundle es diminuto. El mantenedor, Jan Amann, es increíblemente receptivo a los problemas.
react-i18next es el estándar de la industria por una razón. Si tu equipo ya lo conoce, hay valor en ello. Pero hacerlo funcionar correctamente con Server Components requiere una configuración extra que siempre se siente como luchar contra el framework.
next-translate es la opción más ligera. Lo consideraría para un sitio de marketing con interactividad mínima. Para cualquier cosa con formularios, contenido dinámico o pluralización compleja, alcanzarás limitaciones rápidamente.
| next-intl | react-i18next | next-translate | |
|---|---|---|---|
| Soporte Server Component | Nativo | Necesita wrapper | Nativo |
| Curva de aprendizaje | Baja | Media | Baja |
| Pluralización | Formato ICU | ICU con plugin | Solo básica |
| Tamaño del bundle | ~2KB | ~8KB | ~1.5KB |
Para esta guía, estoy usando next-intl. Si empiezas de cero, recomendaría lo mismo.
Configurando todo (La manera correcta)
Instalación
Terminalnpm install next-intl
Estructura de carpetas
Aquí es donde me equivoqué inicialmente. Traté de ser inteligente con la estructura de carpetas y me salió el tiro por la culata. Quédate con esto:
/messages
/en.json
/es.json
/de.json
/app
/[locale]
/layout.tsx
/page.tsx
/(auth)
/login/page.tsx
/i18n
/config.ts
/request.ts
/middleware.ts
Archivos de traducción
Mantén tus archivos JSON organizados por característica, no por página. Aprendí esto por las malas cuando teníamos 40 páginas y los archivos se volvieron inmanejables.
JSON1{ 2 "common": { 3 "buttons": { 4 "submit": "Enviar", 5 "cancel": "Cancelar", 6 "save": "Guardar cambios" 7 }, 8 "errors": { 9 "required": "Este campo es obligatorio", 10 "network": "Algo salió mal. Por favor inténtalo de nuevo." 11 } 12 }, 13 "checkout": { 14 "title": "Completa tu pedido", 15 "shipping": "Dirección de envío", 16 "payment": "Método de pago" 17 } 18}
Los archivos de configuración
Crea i18n/config.ts:
TypeScriptexport const locales = ['en', 'es', 'de', 'fr', 'ja'] as const; export type Locale = (typeof locales)[number]; export const defaultLocale: Locale = 'en';
Y i18n/request.ts:
TypeScript1import { getRequestConfig } from 'next-intl/server'; 2import { notFound } from 'next/navigation'; 3import { locales } from './config'; 4 5export default getRequestConfig(async ({ locale }) => { 6 if (!locales.includes(locale as any)) notFound(); 7 8 return { 9 messages: (await import(`../messages/${locale}.json`)).default 10 }; 11});
Middleware
Aquí es donde ocurre la detección de locale. El middleware se ejecuta en cada solicitud y decide qué idioma mostrar.
TypeScript1import createMiddleware from 'next-intl/middleware'; 2import { locales, defaultLocale } from './i18n/config'; 3 4export default createMiddleware({ 5 locales, 6 defaultLocale, 7 localePrefix: 'always' 8}); 9 10export const config = { 11 matcher: ['/((?!api|_next|.*\\..*).*)'] 12};
Una cosa que me hizo tropezar: esa regex del matcher. Si tienes una carpeta /public con imágenes, asegúrate de que tu matcher no intente localizar esas rutas. La parte .*\\. maneja archivos con extensiones, pero verifica dos veces si tienes activos sin extensión.
Configuración del Layout
Tu layout raíz en app/[locale]/layout.tsx:
TypeScript1import { NextIntlClientProvider } from 'next-intl'; 2import { getMessages } from 'next-intl/server'; 3import { notFound } from 'next/navigation'; 4import { locales } from '@/i18n/config'; 5 6export default async function LocaleLayout({ 7 children, 8 params: { locale } 9}: { 10 children: React.ReactNode; 11 params: { locale: string }; 12}) { 13 if (!locales.includes(locale as any)) { 14 notFound(); 15 } 16 17 const messages = await getMessages(); 18 19 return ( 20 <html lang={locale}> 21 <body> 22 <NextIntlClientProvider messages={messages}> 23 {children} 24 </NextIntlClientProvider> 25 </body> 26 </html> 27 ); 28}
Usando realmente las traducciones
Server Components
Esta es la parte más limpia. Sin hooks, sin contexto, solo async/await:
TypeScript1import { getTranslations } from 'next-intl/server'; 2 3export default async function ProductPage() { 4 const t = await getTranslations('product'); 5 6 return ( 7 <div> 8 <h1>{t('title')}</h1> 9 <p>{t('description')}</p> 10 </div> 11 ); 12}
Client Components
Cuando necesitas interactividad:
TypeScript1'use client'; 2 3import { useTranslations } from 'next-intl'; 4 5export function AddToCartButton() { 6 const t = useTranslations('product'); 7 8 return ( 9 <button onClick={handleAddToCart}> 10 {t('addToCart')} 11 </button> 12 ); 13}
Variables y Pluralización
Aquí es donde brilla el formato ICU. En tu JSON:
JSON1{ 2 "cart": { 3 "items": "{count, plural, =0 {Tu carrito está vacío} one {# artículo en el carrito} other {# artículos en el carrito}}", 4 "total": "Total: {price, number, ::currency/USD}" 5 } 6}
Y en tu componente:
TypeScriptt('cart.items', { count: 3 }) // "3 artículos en el carrito" t('cart.total', { price: 99.99 }) // "Total: $99.99"
La sintaxis plural ICU parece intimidante al principio, pero maneja casos extremos que nunca pensarías. El ruso tiene diferentes formas plurales para números que terminan en 1, 2-4 y 5-20. ICU maneja esto automáticamente.
Consejo profesional: Usa nuestro Editor de Mensajes ICU gratuito para construir y probar visualmente la sintaxis de pluralización ICU antes de añadirla a tus archivos de traducción. Te muestra una vista previa en vivo con diferentes valores de variables.
Cosas que desearía que alguien me hubiera dicho antes
El problema de desajuste de hidratación
Si ves errores de hidratación con fechas o números, es porque el servidor renderizó con un locale pero el cliente se inicializó con otro. La solución:
TypeScript1// En layout.tsx 2<NextIntlClientProvider 3 messages={messages} 4 timeZone="Europe/Berlin" // Sé explícito 5 now={new Date()} // Pasa la hora del servidor 6>
No olvides generateStaticParams
Sin esto, tus páginas de locale no se generarán estáticamente:
TypeScriptexport function generateStaticParams() { return locales.map((locale) => ({ locale })); }
Me perdí esto en un deploy y me preguntaba por qué mi sitio era lento. Cada página se renderizaba en el servidor bajo demanda.
Las cosas de SEO que realmente importan
Las etiquetas Hreflang son cruciales. Google necesita saber sobre tus versiones de idioma:
TypeScript1export async function generateMetadata({ params: { locale } }) { 2 const t = await getTranslations('meta'); 3 4 return { 5 title: t('title'), 6 alternates: { 7 canonical: `https://example.com/${locale}`, 8 languages: { 9 'en': 'https://example.com/en', 10 'es': 'https://example.com/es', 11 'de': 'https://example.com/de', 12 'x-default': 'https://example.com/en' 13 } 14 } 15 }; 16}
Ese x-default es fácil de olvidar pero importante para usuarios cuyo idioma no soportas.
Construyendo un selector de idioma
TypeScript1'use client'; 2 3import { useLocale } from 'next-intl'; 4import { usePathname, useRouter } from 'next-intl/client'; 5 6export function LanguageSwitcher() { 7 const locale = useLocale(); 8 const pathname = usePathname(); 9 const router = useRouter(); 10 11 const switchLocale = (newLocale: string) => { 12 router.replace(pathname, { locale: newLocale }); 13 }; 14 15 return ( 16 <select value={locale} onChange={(e) => switchLocale(e.target.value)}> 17 <option value="en">English</option> 18 <option value="es">Español</option> 19 <option value="de">Deutsch</option> 20 </select> 21 ); 22}
Una trampa: usePathname de next-intl/client devuelve la ruta sin el prefijo locale. El de next/navigation lo incluye. He mezclado estos más veces de las que me gustaría admitir.
Estrategias de URL
Tienes tres opciones:
Mostrar siempre prefijo locale (/en/about, /es/about): Lo más simple de implementar, lo más claro para usuarios y motores de búsqueda. Esto es lo que recomiendo para la mayoría de los sitios.
Ocultar locale por defecto (/about para inglés, /es/about para español): Se ve más limpio pero añade complejidad. Necesitas manejar redirecciones cuidadosamente.
Basado en dominio (example.com, example.es): Lo mejor para SEO en diferentes países, pero requiere más infraestructura.
Para el enfoque de dominio, configurarías next-intl así:
TypeScript1export default createMiddleware({ 2 locales, 3 defaultLocale, 4 domains: [ 5 { domain: 'example.com', defaultLocale: 'en' }, 6 { domain: 'example.es', defaultLocale: 'es' }, 7 { domain: 'example.de', defaultLocale: 'de' } 8 ] 9});
Cuando los archivos JSON se vuelven un dolor
Alrededor de 500 claves de traducción, gestionar archivos JSON manualmente empieza a doler. Algunos problemas con los que me he topado:
- Traductores rompiendo accidentalmente la sintaxis JSON
- Sin forma de ver qué falta en los idiomas
- Copiar y pegar nuevas claves a 12 archivos
- Olvidar eliminar claves borradas de otros idiomas
Solución rápida para problemas JSON: Nuestra herramienta gratuita Comparador de Traducción JSON te ayuda a comparar archivos de traducción entre idiomas y detectar instantáneamente claves faltantes, claves extra y diferencias estructurales.
Aquí es donde ayuda un sistema de gestión de traducción. He estado usando IntlPull para mis proyectos recientes—está construido específicamente para flujos de trabajo de Next.js i18n. El flujo de trabajo se ve así:
Terminal1# Sube tus cadenas fuente 2npx @intlpullhq/cli upload 3 4# Obtén traducciones de IA (o envía a traductores humanos) 5npx @intlpullhq/cli translate --languages es,de,fr 6 7# Baja todo de nuevo 8npx @intlpullhq/cli download
El comando npx @intlpullhq/cli watch es útil durante el desarrollo. Sincroniza cambios mientras codificas.
Por qué IntlPull para proyectos Next.js:
- Integración nativa next-intl - Funciona con tu estructura de carpetas existente
- Traducciones impulsadas por IA - Obtén traducciones instantáneas con conciencia de contexto
- Flujo de trabajo amigable con Git - Tira traducciones directamente a tu repo
- Actualizaciones OTA - Actualiza traducciones sin redeploy (para arreglar errores tipográficos en producción)
- Nivel gratuito - Cubre la mayoría de los proyectos paralelos y startups
Para aplicaciones que necesitan actualizar traducciones sin redeploy, la característica OTA de IntlPull obtiene traducciones en tiempo de ejecución. Pero para la mayoría de los proyectos, el flujo de trabajo CLI es más simple.
Notas de rendimiento
Los Server Components ya te dan una gran victoria manteniendo las traducciones fuera del bundle del cliente. Más allá de eso:
Divide por namespace. No cargues las 2000 claves cuando una página solo necesita 50. En tu llamada getMessages:
TypeScriptconst messages = await getMessages({ namespace: 'checkout' });
Usa Suspense para páginas pesadas. Si tienes una página con mucho contenido traducido:
TypeScript<Suspense fallback={<ProductSkeleton />}> <ProductDetails /> </Suspense>
No sobre-optimices. He visto equipos pasar días afeitando kilobytes de bundles de traducción cuando sus imágenes eran megabytes. Concéntrate en lo que importa.
Preguntas Comunes
¿Puedo mezclar App Router y Pages Router durante la migración?
Sí, y lo recomiendo. Migra página por página. next-intl puede funcionar en ambos, aunque la configuración difiere.
¿Qué hay de idiomas RTL como Árabe y Hebreo?
Establece el atributo dir basado en el locale:
TypeScript1const rtlLocales = ['ar', 'he', 'fa']; 2const dir = rtlLocales.includes(locale) ? 'rtl' : 'ltr'; 3 4return <html lang={locale} dir={dir}>
También necesitarás CSS compatible con RTL. Librerías como Tailwind tienen variantes RTL, o puedes usar propiedades lógicas (margin-inline-start en lugar de margin-left).
¿Cómo manejo claves de traducción en un monorepo?
Mantén traducciones compartidas en un paquete e impórtalas. Para traducciones específicas del proyecto, usualmente las mantengo en cada app.
¿Probando componentes traducidos?
Envuelve tu componente con el proveedor en las pruebas:
TypeScript1render( 2 <NextIntlClientProvider locale="en" messages={messages}> 3 <MyComponent /> 4 </NextIntlClientProvider> 5);
Conclusión
El App Router de Next.js 15 genuinamente hace que i18n sea mejor. Los Server Components eran la pieza faltante que hace que las traducciones se sientan nativas en lugar de atornilladas.
Si recién estás empezando:
- Configura next-intl con la estructura de carpetas de arriba
- Haz que tu enrutamiento funcione con el middleware
- Añade traducciones incrementalmente mientras construyes características
- Cuando superes los archivos JSON, busca un TMS
La configuración inicial toma tal vez una hora. Después de eso, añadir nuevos idiomas es sencillo.
Si quieres probar IntlPull para gestionar traducciones, hay un nivel gratuito que debería cubrir la mayoría de los proyectos paralelos. Pero honestamente, los archivos JSON funcionan bien hasta que no lo hacen. Sabrás cuando necesites algo más.
Preguntas Frecuentes
¿Cuál es la mejor librería i18n para Next.js 15?
next-intl es la mejor librería i18n para Next.js 15 porque está construida específicamente para App Router y Server Components. Ofrece soporte nativo de Server Component sin wrappers, un bundle diminuto de 2KB, soporte completo de TypeScript y formato ICU para pluralización. react-i18next funciona pero requiere configuración extra para Server Components.
¿Next.js 15 tiene soporte i18n integrado?
Next.js 15 proporciona infraestructura de enrutamiento para i18n (URLs basadas en locale vía carpetas [locale] y middleware) pero no incluye funcionalidad de traducción. Necesitas una librería como next-intl, react-i18next o next-translate para manejar las traducciones reales. El segmento dinámico [locale] del App Router es la base para el enrutamiento i18n.
¿Cómo añado múltiples idiomas a Next.js?
Para añadir múltiples idiomas a Next.js 15: (1) Crea una carpeta [locale] en tu directorio app, (2) Añade archivos JSON de traducción en una carpeta messages (en.json, es.json, etc.), (3) Configura middleware para detección de locale y enrutamiento, (4) Usa hooks getTranslations() o useTranslations() para mostrar cadenas traducidas. IntlPull puede automatizar la gestión de traducciones y proporcionar traducciones de IA para nuevos idiomas.
¿Cómo mejoran los Server Components i18n en Next.js?
Los Server Components eliminan el exceso de bundle de traducción en el lado del cliente. En Pages Router, todos los archivos de traducción se enviaban al cliente (los usuarios descargaban cada idioma). Con Server Components, las traducciones se renderizan en el servidor y solo el HTML se envía a los clientes. Una página con 200+ claves de traducción ahora carga instantáneamente porque las cadenas no se suman al bundle de JavaScript.
¿Qué estructura de URL debo usar para apps Next.js multilingües?
Usa prefijo de locale en URLs (/en/about, /es/about) para la mayoría de los sitios—es lo más claro para usuarios y lo mejor para SEO. Configura el middleware next-intl con localePrefix: 'always'. Para targeting específico de país, considera enrutamiento basado en dominio (example.com, example.es) que requiere configuración de DNS y hosting pero proporciona las señales de SEO local más fuertes.
¿Cómo manejo traducciones faltantes en Next.js?
Configura un idioma de respaldo (fallback) en tu configuración i18n para mostrar texto predeterminado cuando falten traducciones. Con next-intl, establece fallbackLocale: 'en' en tu config. Para aplicaciones de producción, usa la detección de traducción faltante de IntlPull para identificar vacíos antes del despliegue y auto-rellenar con traducción de IA durante el desarrollo.
