Next.js multilingüe: next-intl, hreflang y RTL en producción#
Mis tres sitios de producción no tienen nada en común en cuanto a temática — un portfolio de desarrollador, una calculadora para corredores y un servicio de salud sobre el efecto del clima en el cuerpo —, pero comparten exactamente la misma arquitectura de i18n. dodecaidr.pro habla cinco idiomas (ru, en, es, ja, zh-CN), runcalculator.pro también habla cinco, y meteohealth.pro habla seis, incluido el árabe con diseño RTL. Los tres están construidos sobre el App Router de Next.js con next-intl, y los tres tropezaron con los mismos problemas: hreflang roto, traducciones que desaparecen silenciosamente de los tests de paridad, y un layout que se rompe en árabe.
Este artículo no es un tutorial de next-intl desde cero. Es un desglose de las decisiones arquitectónicas concretas que reutilizo en los tres proyectos, con código extraído de runcalculator.pro y meteohealth.pro, y una lista de errores que cometí yo mismo o que evité deliberadamente.
Tres proyectos en producción, un mismo dolor de cabeza#
Cuando un sitio vive en un solo idioma, i18n es una librería de cadenas traducidas. A partir de cinco idiomas, i18n es una decisión arquitectónica que afecta al enrutamiento, al SEO, a las pruebas de contenido y al layout. En los tres proyectos llegué al mismo conjunto de patrones:
localePrefix: 'always'— cada URL conserva su prefijo de locale (/ru/...,/en/...), sin una ruta "desnuda" para el idioma por defecto. Esto simplifica la caché, los logs y el hreflang, pero exige unx-defaultexplícito.- Un middleware/proxy que resuelve el locale antes del renderizado — next-intl determina el locale a partir de la URL, una cookie y
Accept-Language, antes de que la página empiece siquiera a ensamblarse. - hreflang y
x-defaultgenerados desde una única fuente — no escritos a mano en cada página, sino producidos por una función que conoce la lista completa de locales. - Contenido localizado en carpetas paralelas —
content/<type>/<locale>/..., en lugar de un diccionario JSON de decenas de miles de líneas. - Un test de paridad de locales — un script que hace fallar el CI si a un artículo nuevo le falta una de las cinco o seis versiones.
- Verificación de layout RTL — no relevante en los tres proyectos, pero ineludible allí donde hay árabe (meteohealth.pro).
A continuación, cómo se ve cada uno de estos puntos en código.
Una única fuente de verdad: la configuración de locales#
El primer error que vi en proyectos ajenos (y que cometí una vez yo mismo) es una lista de locales duplicada en el middleware, en la configuración de next-intl y en el componente selector de idioma. Añades un locale, y aparece en el selector pero no funciona en el middleware, porque ahí hay un array distinto.
La solución es un único archivo del que importan todos los demás:
// lib/i18n/config.ts
export const locales = ['ru', 'en', 'es', 'ja', 'zh-CN'] as const
export type Locale = (typeof locales)[number]
export const defaultLocale: Locale = 'ru'
export const i18nConfig = {
locales,
defaultLocale,
localePrefix: 'always' as const, // every URL keeps its locale prefix
}
export function isValidLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale)
}
export function getValidLocale(locale: string | undefined): Locale {
if (!locale) return defaultLocale
return isValidLocale(locale) ? locale : defaultLocale
}El middleware, el generador del sitemap, el generador de hreflang y el cargador de contenido MDX importan locales y defaultLocale desde aquí. Añadir un locale nuevo consiste en editar un array y crear los archivos de traducciones y contenido; ninguna lógica de enrutamiento se toca a mano.
next-intl v4: requestLocale y la carga de mensajes#
En next-intl v4, el locale que llega a getRequestConfig no es síncrono: llega como una Promise. Ese cambio rompió parte del código al migrar desde v3 en los proyectos que no comprobaban el tipo. El patrón actual es este:
// i18n.ts
import { notFound } from 'next/navigation'
import { getRequestConfig } from 'next-intl/server'
import { locales, defaultLocale, isValidLocale, getValidLocale, type Locale } from './lib/i18n/config'
export default getRequestConfig(async ({ requestLocale }) => {
// next-intl v4: locale arrives as a Promise
const requested = await requestLocale
if (requested && !isValidLocale(requested)) {
notFound()
}
const locale = getValidLocale(requested)
const [common, marketing] = await Promise.all([
import(`./locales/${locale}/common.json`),
import(`./locales/${locale}/marketing.json`),
])
return {
locale,
messages: {
common: common.default,
marketing: marketing.default,
},
timeZone: getTimeZone(locale),
}
})
function getTimeZone(locale: Locale): string {
const timeZones: Record<Locale, string> = {
ru: 'Europe/Moscow',
en: 'America/New_York',
es: 'Europe/Madrid',
ja: 'Asia/Tokyo',
'zh-CN': 'Asia/Shanghai',
}
return timeZones[locale] ?? 'UTC'
}Dos detalles que rara vez se mencionan en los tutoriales. Primero, notFound() solo debe dispararse cuando el locale se recibió y es inválido — no cuando falta —, o se rompe cualquier ruta donde el locale todavía no se ha resuelto. Segundo, la zona horaria no es un detalle decorativo: si no se fija, Intl.DateTimeFormat y los formateadores de fecha de next-intl caen al TZ del servidor, y la fecha de publicación de un artículo en la versión japonesa del sitio puede mostrar el día anterior.
proxy.ts en lugar de middleware.ts: qué cambió en Next.js 16#
Next.js 16 renombró middleware.ts a proxy.ts — el archivo y la función exportada tienen un nombre nuevo, pero el contrato con next-intl no cambió: createMiddleware de next-intl/middleware sigue envolviéndose en un único handler.
// proxy.ts — renamed from middleware.ts in Next.js 16
import createMiddleware from 'next-intl/middleware'
import { NextRequest } from 'next/server'
import { locales, defaultLocale } from './lib/i18n/config'
const intlMiddleware = createMiddleware({
locales,
defaultLocale,
localePrefix: 'always',
})
export default function proxy(request: NextRequest) {
return intlMiddleware(request)
}
export const config = {
// skip /api, /_next and static files with a dot in the path
matcher: ['/((?!api|_next|.*\\..*).*)'],
}En la práctica, este mismo archivo es un buen lugar para añadir logging de peticiones de extremo a extremo (método, ruta, estado, duración) — no interfiere con las redirecciones de i18n siempre que se llame después de intlMiddleware(request), no en su lugar.
hreflang y x-default: dónde falla el 75% de los sitios#
Hay un dato (Ahrefs) que indica que alrededor del 75% de los sitios con versiones internacionales cometen errores de hreflang: falta de enlace de retorno (la página A enlaza a B, pero no al revés), URLs rotas en las etiquetas y — el más común — un canonical que en todas las versiones de idioma apunta a la página en inglés. Este último error le dice al buscador, en la práctica, "ignora el resto de locales", y así es como los propios dueños del sitio anulan su trabajo de i18n.
Para no escribir hreflang a mano en cada página, lo genero con una única función y la reutilizo tanto en generateMetadata como en el sitemap:
// Generates alternates for both metadata and sitemap entries
function generateAlternates(route: string) {
const alternates: Record<string, string> = {}
for (const locale of locales) {
alternates[locale] = route
? `${baseUrl}/${locale}/${route}`
: `${baseUrl}/${locale}`
}
// With localePrefix: 'always' there is no locale-neutral URL,
// so x-default has to point somewhere — the default locale is the pragmatic choice
alternates['x-default'] = route
? `${baseUrl}/${defaultLocale}/${route}`
: `${baseUrl}/${defaultLocale}`
return alternates
}
// app/[locale]/articles/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params
return {
alternates: {
canonical: `${baseUrl}/${locale}/articles/${slug}`,
languages: generateAlternates(`articles/${slug}`),
},
}
}Un matiz fácil de pasar por alto: x-default debe aparecer una sola vez, apuntando a la página para usuarios cuyo idioma no coincide con ninguno de tus locales — normalmente la home o una página de selección de idioma. Con localePrefix: 'always', sencillamente no existe una URL neutral, así que x-default apunta al locale por defecto como compromiso pragmático, no como "respuesta correcta" — vale la pena documentarlo explícitamente en el código para que nadie lo "arregle" dentro de seis meses hacia una ruta raíz inexistente.
Cada página también debe referenciarse a sí misma (hreflang autorreferencial) — sin eso, el buscador tiene derecho a ignorar todo el conjunto de etiquetas alternate de la página.
Contenido MDX localizado en carpetas paralelas, validado con Zod y verificado con un test de paridad#
Un diccionario JSON de traducciones de interfaz es una solución razonable para botones y etiquetas, pero no para contenido: artículos, descripciones de proyectos, políticas de privacidad. Mantener texto largo en JSON significa perder el formato, los bloques de código y la posibilidad de simplemente abrir un archivo en el editor y escribir. Por eso el contenido vive en carpetas paralelas por locale:
content/articles/
├── ru/general/nextjs-i18n-next-intl.mdx
├── en/general/nextjs-i18n-next-intl.mdx
├── es/general/nextjs-i18n-next-intl.mdx
├── ja/general/nextjs-i18n-next-intl.mdx
└── zh-CN/general/nextjs-i18n-next-intl.mdxEl frontmatter de cada archivo se valida contra un esquema Zod al leerlo: campos obligatorios, valores de categoría permitidos y fechas en formato YYYY-MM-DD — si un traductor (o yo mismo, a las 23:00) olvida un campo o comete un error tipográfico en una categoría, el build falla con un error claro de Zod en lugar de mostrar silenciosamente una tarjeta de artículo vacía en producción.
Un script de test de paridad aparte comprueba que cada slug tenga un archivo en todos los locales y que la estructura de los diccionarios JSON (las claves, no los valores) coincida entre idiomas. Sin ese test, el escenario típico es este: un artículo se publica en ruso, se fusiona el PR, y un mes después resulta que la versión japonesa del sitio sigue mostrando un 404 — o peor, un título antiguo tomado de un valor por defecto compartido.
El RTL no es solo dir="rtl": lecciones de meteohealth.pro#
De los tres proyectos, solo meteohealth.pro habla árabe, y ahí es exactamente donde aparecieron bugs de RTL que nunca surgen en ru/en/es/ja/zh-CN — ninguno de los cuales invierte el layout. dir="rtl" en <html> cubre aproximadamente el 60% del trabajo: el texto se alinea y fluye en la dirección correcta. El 40% restante son los iconos, que deben invertirse en espejo (una flecha "adelante" no puede apuntar hacia atrás), los campos de entrada, que deben aceptar dígitos y texto en árabe sin invertir la dirección carácter a carácter, y el layout construido con margin-left/padding-right, que en RTL simplemente termina del lado equivocado.
// app/[locale]/layout.tsx
import { locales, type Locale } from '@/lib/i18n/config'
const RTL_LOCALES: Locale[] = ['ar'] // meteohealth.pro only
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: Locale }>
}) {
const { locale } = await params
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr'
return (
<html lang={locale} dir={dir}>
<body className="ms-0 pe-4 rtl:pe-0 rtl:ps-4">
{children}
</body>
</html>
)
}Conclusiones prácticas aplicables a cualquier proyecto con un locale RTL:
- Usar propiedades CSS lógicas (
margin-inline-start,padding-inline-end; en Tailwind,ms-/ps-/me-/pe-) en lugar de físicas (margin-left/padding-right) — así el espejado ocurre automáticamente, sin duplicar clases conrtl:. - Para los casos que las propiedades lógicas no cubren (iconos, transformaciones concretas), usar el modificador
rtl:de Tailwind, por ejemplortl:rotate-180para flechas. - El contenido mixto — texto árabe con fragmentos en alfabeto latino (marcas, correos, código) — conviene envolverlo en
<bdi>, o el orden de los caracteres en el límite de dirección puede desordenarse visualmente. - El RTL no se puede validar "a ojo" una sola vez antes de un release. La comparación de capturas de pantalla de las pantallas clave (tarjetas, formularios, navegación) en variantes
ltryrtldetecta regresiones que de otro modo solo aparecen en quejas de usuarios. - Usar texto árabe real en el entorno de pruebas desde el primer día, no "añadimos las traducciones después". Los bugs de RTL sobre latín pseudo-espejado y sobre árabe real son bugs distintos.
Comparación de los tres proyectos y checklist#
| Proyecto | Locales | RTL | Contenido | Particularidad |
|---|---|---|---|---|
| dodecaidr.pro | 5 (ru, en, es, ja, zh-CN) | no | MDX + esquemas Zod, test de paridad en CI | El portfolio y los artículos son contenido, no cadenas de UI |
| runcalculator.pro | 5 | no | Descripciones en MDX y fórmulas de cálculo localizadas | Los formatos numéricos (ritmo, distancia) dependen del locale, no solo el texto |
| meteohealth.pro | 6 (+ árabe) | sí | MDX más capturas de pantalla y políticas localizadas en 5+ idiomas | El único proyecto donde el RTL es una verificación diaria de layout, no una teoría |
Resumiendo lo que se traslada sin cambios de un proyecto a otro:
- Los locales y el locale por defecto viven en un único archivo, no en tres.
- El middleware/
proxy.tsresuelve el locale antes del renderizado y no decide nada por su cuenta por encima de next-intl. - hreflang y
x-defaultse generan con una función, no se copian a mano página por página. - El contenido vive en MDX por locale, con frontmatter validado con Zod y un test de paridad en CI.
- El RTL no es un ajuste puntual de
dir— son propiedades CSS lógicas más un pase de pruebas dedicado siempre que el árabe o el hebreo estén en la lista de locales.
Los cinco puntos suenan a sentido común cuando se escriben en papel. En la práctica, cada uno se incumplió al menos una vez en alguno de los tres proyectos antes de convertirse en regla.



