Índice
El App Router cambió la forma en que pensamos sobre la obtención de datos en Next.js. También cambió cómo deberíamos pensar sobre i18n. La mayoría de las guías que existen todavía tratan la traducción como una preocupación del lado del cliente: un paquete de archivos JSON cargados en tiempo de ejecución. Eso tenía sentido en la era del Pages Router. En 2026, con React Server Components como estándar, podemos hacerlo mejor.
Este artículo recorre los patrones que hemos adoptado para i18n nativo de RSC: cómo detectar el idioma en el middleware, cómo obtener traducciones en server components sin sobrecarga del cliente, cómo hidratar islas de cliente de forma limpia, y cómo la entrega CDN-first cambia la ecuación por completo.
Por qué RSC cambia los fundamentos de i18n
En el Pages Router, getServerSideProps o getStaticProps obtenían las traducciones en el servidor y luego las serializaban en el bundle de la página. El cliente rehidrataba todo, incluidos los diccionarios de traducción que nunca cambiaban por usuario.
Los Server Components eliminan ese patrón. No hay paso de hidratación para la salida RSC: el HTML es definitivo. Esto significa que podemos obtener traducciones en un server component, renderizar cadenas traducidas directamente en HTML y enviar cero JavaScript relacionado con traducciones al cliente. Sin bundle de diccionario, sin búsqueda en tiempo de ejecución, sin discrepancias de hidratación.
La salvedad: los client components que necesitan traducciones (formularios interactivos, interfaces dinámicas) siguen requiriendo acceso del lado del cliente. El patrón queda claro en cuanto separas explícitamente las dos responsabilidades.
Middleware: detección de idioma antes de que la solicitud llegue a tu aplicación
El enrutamiento por idioma ocurre en el middleware porque necesita ejecutarse en el edge, antes de que se renderice cualquier componente.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es'] as const
type Locale = typeof SUPPORTED_LOCALES[number]
const DEFAULT_LOCALE: Locale = 'en'
function detectLocale(request: NextRequest): Locale {
// 1. Check URL path prefix
const pathname = request.nextUrl.pathname
const pathLocale = pathname.split('/')[1] as Locale
if (SUPPORTED_LOCALES.includes(pathLocale)) {
return pathLocale
}
// 2. Check Accept-Language header
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')
.map(lang => lang.split(';')[0].trim().slice(0, 2) as Locale)
.find(lang => SUPPORTED_LOCALES.includes(lang))
if (preferred) return preferred
}
return DEFAULT_LOCALE
}
export function middleware(request: NextRequest) {
const locale = detectLocale(request)
const pathname = request.nextUrl.pathname
// Redirect root to locale-prefixed path
if (!SUPPORTED_LOCALES.some(l => pathname.startsWith(`/${l}`))) {
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
)
}
// Inject locale into headers for server components
const response = NextResponse.next()
response.headers.set('x-locale', locale)
return response
}
export const config = {
matcher: ['/((?!api|_next|_static|favicon.ico).*)'],
}
El header x-locale es el puente entre el middleware y los server components. Lo establecemos en cada solicitud para que cualquier server component pueda leerlo sin necesidad de prop drilling.
Estructura del App Router: el idioma como segmento de layout
El enfoque convencional del App Router envuelve las rutas específicas de idioma bajo un segmento [locale]:
app/
[locale]/
layout.tsx ← establece el contexto de idioma, obtiene traducciones base
page.tsx ← página de inicio
blog/
[slug]/
page.tsx
dashboard/
page.tsx
api/
...
El archivo [locale]/layout.tsx es donde inicializamos las traducciones para todo el subárbol:
// app/[locale]/layout.tsx
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { TranslationProvider } from '@/components/translation-provider'
import { fetchTranslations } from '@/lib/translations'
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es']
interface LayoutProps {
children: React.ReactNode
params: { locale: string }
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params
if (!SUPPORTED_LOCALES.includes(locale)) {
notFound()
}
// Fetch base translations for layout-level strings
const baseTranslations = await fetchTranslations(locale, 'common')
return (
<html lang={locale}>
<body>
<TranslationProvider locale={locale} messages={baseTranslations}>
{children}
</TranslationProvider>
</body>
</html>
)
}
Server Components: obtener traducciones en el edge
Aquí está el cambio clave: en lugar de empaquetar archivos de idioma con tu aplicación, obtenemos las traducciones desde una CDN en el momento de la solicitud. La función se parece a cualquier otra obtención de datos asíncrona:
// lib/translations.ts
import { cache } from 'react'
type Messages = Record<string, string>
// React cache() deduplicates fetches within a single render pass
export const fetchTranslations = cache(async (
locale: string,
namespace: string
): Promise<Messages> => {
const url = `https://cdn.better-i18n.com/v1/${locale}/${namespace}.json`
const res = await fetch(url, {
next: {
revalidate: 3600, // CDN-delivered, revalidate hourly
tags: [`translations-${locale}-${namespace}`],
},
})
if (!res.ok) {
// Fall back to English rather than breaking the page
if (locale !== 'en') {
return fetchTranslations('en', namespace)
}
throw new Error(`Failed to fetch translations: ${res.status}`)
}
return res.json()
})
El wrapper cache() de React garantiza que varios server components en la misma página que soliciten el mismo namespace solo disparen una solicitud de red por pasada de renderizado. Combinado con el caché de fetch de Next.js, la respuesta de la CDN normalmente se sirve desde la caché del edge, con una latencia de submilisegundo para las búsquedas de traducción.
Un server component que usa este patrón:
// app/[locale]/blog/[slug]/page.tsx
import { fetchTranslations } from '@/lib/translations'
import { getPost } from '@/lib/posts'
interface PageProps {
params: { locale: string; slug: string }
}
export default async function BlogPost({ params }: PageProps) {
const { locale, slug } = await params
// Both fetches happen in parallel
const [t, post] = await Promise.all([
fetchTranslations(locale, 'blog'),
getPost(slug),
])
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<p className="meta">
{t['blog.readTime'].replace('{n}', String(post.readTimeMinutes))}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<footer>
<a href={`/${locale}/blog`}>{t['blog.backToList']}</a>
</footer>
</article>
)
}
Cero JavaScript en el cliente. Sin hidratación. La búsqueda de la clave de traducción ocurre en el servidor y el resultado se serializa como HTML.
Claves de traducción con tipado seguro
Las búsquedas de claves basadas en cadenas (t['blog.readTime']) fallan silenciosamente cuando las claves cambian. Generamos un tipo a partir del archivo de mensajes del idioma base:
// types/translations.ts (generated, do not edit manually)
// Generated from: en/blog.json
export type BlogNamespaceKey =
| 'blog.readTime'
| 'blog.backToList'
| 'blog.publishedOn'
| 'blog.updatedOn'
| 'blog.authorBy'
| 'blog.sharePost'
| 'blog.relatedPosts'
// Typed accessor
export function t(messages: Record<string, string>, key: BlogNamespaceKey): string {
const value = messages[key]
if (value === undefined) {
// In development, surface the missing key
if (process.env.NODE_ENV === 'development') {
console.warn(`Missing translation key: ${key}`)
}
return key
}
return value
}
El paso de generación se ejecuta como parte del pipeline de CI cada vez que cambian los archivos de traducción. TypeScript detecta entonces las claves faltantes o renombradas en tiempo de compilación, no en tiempo de ejecución en producción.
Este es uno de los ámbitos donde una plataforma como Better i18n ofrece una ventaja real: la generación de tipos es automática y la salida de tipos se actualiza en el editor en cuanto publicas nuevas claves desde el dashboard.
Hidratación de client components: islas de traducción
No todo puede ser un server component. Los formularios, modales e interfaces interactivas necesitan acceso del lado del cliente a las traducciones para cadenas dinámicas: mensajes de error, feedback de validación, etiquetas de estado asíncrono.
El patrón es un context provider que serializa el subconjunto de mensajes relevante:
// components/translation-provider.tsx
'use client'
import { createContext, useContext } from 'react'
type Messages = Record<string, string>
const TranslationContext = createContext<{
messages: Messages
locale: string
} | null>(null)
export function TranslationProvider({
children,
messages,
locale,
}: {
children: React.ReactNode
messages: Messages
locale: string
}) {
return (
<TranslationContext.Provider value={{ messages, locale }}>
{children}
</TranslationContext.Provider>
)
}
export function useTranslations(namespace?: string) {
const ctx = useContext(TranslationContext)
if (!ctx) throw new Error('useTranslations must be used inside TranslationProvider')
return {
t: (key: string) => {
const fullKey = namespace ? `${namespace}.${key}` : key
return ctx.messages[fullKey] ?? fullKey
},
locale: ctx.locale,
}
}
El server component del layout obtiene los mensajes y los pasa al provider. Los client components llaman a useTranslations() sin disparar ninguna solicitud adicional:
// components/contact-form.tsx
'use client'
import { useTranslations } from '@/components/translation-provider'
import { useState } from 'react'
export function ContactForm() {
const { t } = useTranslations('forms')
const [error, setError] = useState<string | null>(null)
async function handleSubmit(formData: FormData) {
const result = await submitContact(formData)
if (!result.ok) {
setError(t('contact.submitError'))
}
}
return (
<form action={handleSubmit}>
<label>{t('contact.nameLabel')}</label>
<input name="name" required />
<label>{t('contact.emailLabel')}</label>
<input name="email" type="email" required />
<button type="submit">{t('contact.submitButton')}</button>
{error && <p role="alert">{error}</p>}
</form>
)
}
La restricción clave: solo pasa el o los namespaces que el client component realmente necesita. Serializar el diccionario de traducción completo en el bundle del cliente anula el propósito.
Rutas estáticas: generateStaticParams
Para páginas generadas estáticamente (páginas de marketing, entradas de blog, documentación), usamos generateStaticParams para prerenderizar todos los idiomas en tiempo de build:
// app/[locale]/page.tsx
export async function generateStaticParams() {
const locales = ['en', 'de', 'fr', 'es']
return locales.map(locale => ({ locale }))
}
export async function generateMetadata({ params }: { params: { locale: string } }) {
const { locale } = await params
const t = await fetchTranslations(locale, 'home')
return {
title: t['home.meta.title'],
description: t['home.meta.description'],
alternates: {
languages: {
'en': '/en',
'de': '/de',
'fr': '/fr',
'es': '/es',
},
},
}
}
Con traducciones entregadas por CDN, el build obtiene los archivos de mensajes desde la CDN en tiempo de compilación y los integra en el HTML estático. En tiempo de ejecución, la CDN sirve el archivo estático: el tiempo de búsqueda de traducciones es efectivamente cero.
Comparación con next-intl y next-i18next
next-intl es la biblioteca más madura compatible con el App Router. Gestiona gran parte del boilerplate de middleware y contexto descrito anteriormente. La contrapartida es que sigue asumiendo que los archivos de idioma viven en tu repositorio (/messages/en.json, /messages/de.json). Cada nueva traducción requiere un despliegue de código.
next-i18next fue construido para el Pages Router. En proyectos con App Router es, en el mejor caso, una capa de compatibilidad. Lo evitaríamos en proyectos nuevos.
El enfoque CDN-first (lo que hemos descrito anteriormente) desacopla el contenido de traducción de los despliegues de código. Actualiza una traducción al francés y estará disponible en la CDN en segundos: sin redeploy, sin PR, sin esperar a que pase el CI. El equipo de localización opera de forma independiente al equipo de ingeniería.
El argumento del menor esfuerzo operativo para los enfoques basados en archivos («es más sencillo») se derrumba rápidamente cuando tienes 20 idiomas y un equipo de localización dedicado. Fusionar PRs de traducción es ruido, no valor.
Lo que haríamos de forma diferente en 2026
- Traducciones en streaming:
<Suspense>alrededor de componentes con muchas traducciones mientras se resuelve la obtención desde la CDN, útil en casos extremos donde las cachés frías de la CDN causan latencia. - Granularidad de namespace por componente: en lugar de obtener datos a nivel de layout, obtener solo los namespaces que cada componente necesita. Reduce el tamaño del payload en páginas con requisitos de traducción acotados.
- Reglas de plural y formato: usar
Intl.PluralRuleseIntl.NumberFormatdirectamente en lugar de depender de abstracciones de bibliotecas. Las APIs del navegador ya son fiables en todos los entornos compatibles.
Primeros pasos
Los patrones anteriores funcionan con cualquier fuente de traducción entregada por CDN. Si quieres saltarte la configuración de infraestructura y obtener claves con tipado seguro, entrega CDN automatizada y un editor amigable para traductores listo para usar, la integración de Better i18n con Next.js cubre esta pila: helpers de middleware, generación de claves tipadas y un dashboard que tu equipo de traducción puede usar sin tocar el código.
Consulta la documentación para desarrolladores para obtener la referencia completa de la API y las guías de integración del SDK.