Next.js i18n a Escala: 30 Idiomas, 91K Páginas, Vercel ISR

El año pasado, entregamos un proyecto de Next.js que aún me pone un poco nervioso cuando pienso en ello. Treinta idiomas. Más de 91,000 páginas generadas estáticamente. Vercel ISR manteniendo todo fresco. El tipo de proyecto donde una decisión arquitectónica incorrecta significa que estás mirando compilaciones de 4 horas, facturas de hosting de $800/mes, o -- en el peor de los casos -- un sitio que simplemente no funciona en coreano.

Esta es la historia de cómo lo hicimos bien (y las partes donde no, al principio). Si estás construyendo una aplicación Next.js internacionalizada a gran escala y te preguntas si ISR puede realmente manejarlo en producción, este artículo es para ti.

Next.js i18n a Escala: 30 Idiomas, 91K Páginas, Vercel ISR

Tabla de Contenidos

El Problema: Por Qué 91K Páginas Es una Bestia Diferente

Déjame establecer el escenario. El cliente era una marca de comercio electrónico empresarial que se expandía a 30 mercados. Cada mercado necesitaba:

  • Páginas de productos localizadas (~2,800 productos × 30 locales = 84,000 páginas)
  • Páginas de categorías (~120 categorías × 30 locales = 3,600 páginas)
  • Páginas de marketing impulsadas por CMS (~120 × 30 = 3,600 páginas)
  • Total: aproximadamente 91,200 URLs únicas

Con getStaticPaths plano y generación estática completa, la compilación inicial iba a tomar entre 3 y 5 horas. Eso no es una exageración. Hicimos benchmarks de prototipos tempranos y vimos que el número subía. Cada despliegue significaría horas de riesgo de inactividad, y el equipo de contenido quería publicar actualizaciones varias veces al día.

SSR tampoco era una opción. Los patrones de tráfico del cliente mostraban picos masivos durante eventos de ventas -- estamos hablando de 50K usuarios concurrentes. Renderizar por servidor 91K variantes posibles de páginas bajo esa carga requeriría computación seria e introduciría latencia que mata las tasas de conversión.

ISR era la respuesta. Pero ISR a esta escala tiene su propio conjunto de desafíos que la documentación de Next.js realmente no te prepara para enfrentar.

Decisiones Arquitectónicas que Tomamos al Principio

Antes de escribir una sola línea de código i18n, tomamos tres decisiones arquitectónicas que nos ahorraron meses de dolor después.

Decisión 1: Enrutamiento de Subruta, No Dominios

Next.js admite dos estrategias i18n: enrutamiento de subruta (/fr/products/...) y enrutamiento de dominio (fr.example.com). Elegimos enrutamiento de subruta. Aquí está el por qué:

Factor Enrutamiento de Subruta Enrutamiento de Dominio
Complejidad DNS/SSL Dominio único 30 dominios/subdominios para gestionar
Despliegue de Vercel Un proyecto Un proyecto (pero sobrecarga de configuración de dominio)
Capital de enlace SEO Consolidado en un dominio Dividido entre dominios
Eficiencia de caché CDN Mejor (caché de borde compartido) Fragmentado
Configuración de análisis Más simple 30 propiedades o filtrado complejo

Para la mayoría de proyectos con menos de 50 locales, el enrutamiento de subruta es la opción. El enrutamiento de dominio tiene sentido cuando necesitas dominios de nivel superior específicos de país por razones legales o cuando tus mercados tienen arquitecturas de contenido fundamentalmente diferentes.

Decisión 2: next-intl Sobre next-i18next

Evaluamos ambas librerías extensamente. En 2025, next-intl (v4.x) se ha convertido en la opción más fuerte para proyectos con App Router, aunque estábamos en Pages Router para esta compilación. Incluso en Pages Router, next-intl nos proporcionó:

  • Mejor soporte de TypeScript con claves de mensajes seguras de tipo
  • Paquete de cliente más pequeño (aproximadamente 2.1KB gzip vs ~5KB para next-i18next)
  • Soporte nativo para formato de mensaje ICU (plurales, género, formato de números)
  • Configuración más simple para páginas ISR

Decisión 3: Generación Parcial Estática + ISR

Esta fue la importante. En lugar de intentar generar estáticamente todas las 91K páginas en el momento de la compilación, pre-construimos solo las páginas con mayor tráfico (aproximadamente 8,000) y dejamos que ISR manejara el resto bajo demanda.

// pages/[locale]/products/[slug].tsx
export async function getStaticPaths() {
  // Solo pre-generar los 100 productos principales × top 5 locales
  const topProducts = await getTopProducts(100);
  const primaryLocales = ['en', 'de', 'fr', 'es', 'ja'];
  
  const paths = topProducts.flatMap(product =>
    primaryLocales.map(locale => ({
      params: { slug: product.slug, locale },
    }))
  );

  return {
    paths,
    fallback: 'blocking', // ISR maneja todo lo demás
  };
}

Esto redujo nuestro tiempo de compilación de más de 3 horas a aproximadamente 12 minutos. Las 83,000 páginas restantes se generan en la primera solicitud y se almacenan en caché en el borde.

Next.js i18n a Escala: 30 Idiomas, 91K Páginas, Vercel ISR - arquitectura

Configurando Next.js i18n para 30 Locales

La configuración i18n integrada de Next.js en next.config.js maneja la detección y enrutamiento de locales. Así es como se veía nuestra configuración (abreviada):

// next.config.js
const nextConfig = {
  i18n: {
    locales: [
      'en', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'pl', 'cs', 'da',
      'sv', 'fi', 'nb', 'hu', 'ro', 'bg', 'hr', 'sk', 'sl', 'el',
      'tr', 'ja', 'ko', 'zh-CN', 'zh-TW', 'th', 'vi', 'id', 'ms', 'ar'
    ],
    defaultLocale: 'en',
    localeDetection: false, // Lo manejamos nosotros
  },
};

Un par de cosas a notar aquí. Deshabilitamos localeDetection porque la detección integrada (basada en encabezados Accept-Language) causaba problemas con el almacenamiento en caché de ISR. Cuando la CDN de Vercel almacena en caché una página, el locale debe ser determinístico desde la URL, no desde encabezados. Permitir que Next.js redirija automáticamente basándose en el idioma del navegador significaba fallos de caché y comportamiento inconsistente.

En su lugar, construimos un middleware personalizado de detección de locale que se ejecuta solo en la ruta raíz:

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

const SUPPORTED_LOCALES = ['en', 'de', 'fr', /* ... */];
const DEFAULT_LOCALE = 'en';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  
  // Solo redirigir en la ruta raíz
  if (pathname === '/') {
    const acceptLanguage = request.headers.get('accept-language') || '';
    const preferred = acceptLanguage.split(',')[0]?.split('-')[0] || DEFAULT_LOCALE;
    const locale = SUPPORTED_LOCALES.includes(preferred) ? preferred : DEFAULT_LOCALE;
    
    return NextResponse.redirect(new URL(`/${locale}`, request.url));
  }
  
  return NextResponse.next();
}

export const config = {
  matcher: ['/'],
};

Estructura de Archivos de Traducción

Con 30 idiomas, la gestión de archivos de traducción se convierte en una preocupación real. Organizamos las traducciones por espacio de nombres:

messages/
├── en/
│   ├── common.json
│   ├── product.json
│   ├── checkout.json
│   └── marketing.json
├── de/
│   ├── common.json
│   ├── product.json
│   └── ...
└── ar/
    └── ...

La carga total de traducción en todos los idiomas fue de aproximadamente 4.2MB. Pero como cargamos traducciones por página usando getStaticProps, cada página individual solo carga 15-40KB de datos de traducción para su locale y espacio de nombres. Eso es crítico -- no quieres enviar los 30 locales al cliente.

export async function getStaticProps({ locale }: GetStaticPropsContext) {
  return {
    props: {
      messages: {
        ...(await import(`../../messages/${locale}/common.json`)).default,
        ...(await import(`../../messages/${locale}/product.json`)).default,
      },
    },
    revalidate: 300, // ISR: revalidar cada 5 minutos
  };
}

Soporte RTL para Árabe

El árabe era el único idioma RTL en nuestro conjunto. Lo manejamos con un envoltorio de diseño simple:

const direction = locale === 'ar' ? 'rtl' : 'ltr';

return (
  <html lang={locale} dir={direction}>
    <body className={direction === 'rtl' ? 'font-arabic' : 'font-sans'}>
      {children}
    </body>
  </html>
);

Además de la variante rtl: de Tailwind para ajustes de espaciado y diseño. Esto funcionó sorprendentemente bien -- tal vez el 5% de nuestro CSS necesitaba anulaciones específicas de RTL.

La Estrategia ISR que Realmente Funcionó

ISR (Regeneración Estática Incremental) es el héroe de esta historia, pero usarlo bien a escala requiere entender cómo funciona realmente la infraestructura de Vercel.

Tiempo de Revalidación

Usamos diferentes períodos de revalidación dependiendo del tipo de contenido:

Tipo de Página Período de Revalidación Razonamiento
Páginas de producto 300s (5 min) Precios/stock cambian frecuentemente
Páginas de categoría 900s (15 min) Listados de productos se actualizan menos frecuentemente
Páginas de marketing/CMS 3600s (1 hora) Los cambios de contenido se planifican
Página de inicio por locale 600s (10 min) Balance entre frescura y almacenamiento en caché

Revalidación Bajo Demanda

Para actualizaciones críticas (cambios de precio, agotamiento de stock), configuramos revalidación bajo demanda a través de webhook desde nuestro CMS headless:

// pages/api/revalidate.ts
import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const { secret, slug, locales } = req.body;
  
  if (secret !== process.env.REVALIDATION_SECRET) {
    return res.status(401).json({ message: 'Secreto inválido' });
  }

  try {
    const targetLocales = locales || ['en']; // Por defecto a inglés si no se especifica
    
    const revalidations = targetLocales.map((locale: string) =>
      res.revalidate(`/${locale}/products/${slug}`)
    );
    
    await Promise.all(revalidations);
    
    return res.json({ revalidated: true, paths: targetLocales.length });
  } catch (err) {
    return res.status(500).json({ message: 'Error revalidando' });
  }
}

Una trampa: cuando revalidas un producto que existe en 30 locales, estás haciendo 30 llamadas de revalidación. Para una actualización masiva de 100 productos, eso son 3,000 solicitudes de revalidación. Tuvimos que agregar limitación de velocidad y encolar estas a través de una función serverless para evitar alcanzar los límites de API de Vercel.

El Patrón Stale-While-Revalidate

La belleza de ISR es que sirve contenido antiguo mientras regenera en segundo plano. Para este proyecto, eso significaba que los usuarios siempre obtenían una respuesta rápida (HTML en caché del borde de Vercel), incluso si los datos tenían hasta 5 minutos de antigüedad. Para un sitio de comercio electrónico, este era un equilibrio aceptable -- el flujo de carrito y pago siempre golpeaba APIs en vivo para stock/precios en tiempo real.

Canalización de Contenido e Integración CMS Headless

El contenido vivía en un CMS headless (Contentful, en este caso, aunque hemos hecho configuraciones similares con Sanity y Storyblok para otros clientes -- ver nuestros servicios de desarrollo CMS headless para más información al respecto).

El modelo de localización de Contentful funcionó bien para 30 locales. Cada entrada tiene valores de campo específicos de locale, y su API admite consultas por locale. Pero hay una consideración de rendimiento: obtener un producto con datos de todos los 30 locales es significativamente más grande que obtener un locale.

Siempre consultamos por un único locale en getStaticProps:

const product = await contentfulClient.getEntry(productId, {
  locale: mapToContentfulLocale(locale), // 'en-US', 'de-DE', etc.
  include: 2, // Resolver 2 niveles de entradas vinculadas
});

Esto mantuvo los tiempos de respuesta de API por debajo de 200ms incluso para entradas de productos complejas con múltiples referencias.

Gestión de Traducciones

Para traducciones de UI (botones, etiquetas, mensajes de error), usamos Crowdin integrado con nuestro repositorio de Git. El flujo de trabajo:

  1. Los desarrolladores agregan nuevas cadenas en inglés a messages/en/*.json
  2. Crowdin se sincroniza y notifica a los traductores
  3. Las traducciones regresan como PRs
  4. CI valida la estructura JSON y completitud
  5. Las traducciones faltantes vuelven al inglés

La estrategia de alternativa es crítica. Nunca quieres una página de producción que muestre claves de traducción como product.add_to_cart. Nuestra cadena de alternativa fue: locale solicitado → familia de idioma (ej., pt-BRpt) → Inglés.

Resultados de Rendimiento y Core Web Vitals

Después del lanzamiento, aquí está lo que medimos en los 30 locales:

Métrica Objetivo Real (P75) Notas
LCP < 2.5s 1.8s Impacto de caché ISR
FID < 100ms 45ms JavaScript del lado del cliente mínimo
CLS < 0.1 0.03 La estrategia de carga de fuentes ayudó
TTFB < 800ms 120ms Borde de Vercel, páginas en caché
TTFB (fallo de caché) < 2s 1.4s ISR generando en primera solicitud
Tiempo de compilación < 20min 11min 40s Solo pre-generando 8K páginas

Los números de TTFB son la estrella aquí. 120ms para páginas en caché significa que los usuarios en Tokio, São Paulo y Frankfurt obtienen respuestas rápidas desde nodos de borde cercanos. Los 1.4s para fallos de caché es el tiempo de generación de ISR -- aceptable porque solo sucede una vez por página por período de revalidación.

Carga de Fuentes para 30 Idiomas

Un desafío de rendimiento específico de sitios multilingües: fuentes. No puedes usar una única familia de fuentes para 30 idiomas. Necesitábamos:

  • Latín/Cirílico: Inter (la mayoría de idiomas europeos)
  • Árabe: Noto Sans Arabic
  • CJK: Noto Sans JP/KR/SC/TC
  • Tailandés: Noto Sans Thai

Usar next/font con carga de fuentes por locale evitó descargas innecesarias de fuentes. Un usuario que visita el sitio en japonés solo descarga Noto Sans JP, no las fuentes árabe o tailandesa.

Desglose de Costos en Vercel

Hablemos de dinero, porque aquí es donde ISR a gran escala se pone interesante. Aquí está nuestro desglose de facturas mensuales de Vercel en 2025:

Elemento de Línea Costo Mensual Notas
Plan Vercel Pro $20/asiento × 4 Plan de equipo base
Ancho de banda (8TB/mes) ~$320 $40/TB después del primer 1TB
Ejecuciones de Serverless Function ~$180 Regeneración ISR + rutas API
Ejecuciones de Edge Middleware ~$45 Detección de locale
Escrituras de ISR ~$90 Operaciones de escritura de caché
Total ~$715/mes

Para un sitio que maneja 2M+ vistas de página/mes en 30 locales, $715 es extremadamente razonable. La alternativa -- ejecutar SSR en infraestructura dedicada -- habría costado $2,000-4,000/mes para rendimiento y confiabilidad equivalentes.

Una cosa a observar: los costos de escritura de caché de ISR pueden aumentar si activas revalidación masiva. Tuvimos un incidente donde una publicación masiva de CMS activó revalidación para 15,000 páginas simultáneamente. Ese único evento costó aproximadamente $40 en ejecuciones de función extra. Ahora procesamos llamadas de revalidación en lotes con un retraso de 100ms entre ellas.

Errores que Cometimos y Cómo los Corregimos

Estaría mintiendo si dijera que esto fue sin problemas desde el primer día. Aquí están los mayores errores:

Error 1: Generar Todos los Locales en Tiempo de Compilación

Nuestro primer enfoque intentó pre-generar cada página en cada locale. La compilación se ejecutó durante 3 horas y 47 minutos. Luego falló porque el límite de tiempo de compilación de Vercel (en Pro) es de 45 minutos. Incluso después de pasar a un servidor de compilación personalizado, el proceso de despliegue fue miserable.

Solución: Generación parcial pre-compilada con fallback: 'blocking'. Compilar solo las páginas que importan más, dejar que ISR maneje la cola larga.

Error 2: No Establecer `fallback` Correctamente

Inicialmente usamos fallback: true en lugar de fallback: 'blocking'. La diferencia importa: true sirve un estado de esqueleto/carga en la primera solicitud, mientras que blocking espera a que se genere la página. Con true, teníamos errores de hidratación porque nuestros componentes de producto esperaban datos que no estaban allí durante el renderizado de alternativa.

Solución: Cambiado a fallback: 'blocking'. El primer visitante a una página sin caché espera 1-2 segundos, pero todos después de eso obtienen la versión en caché instantáneamente.

Error 3: Las Etiquetas SEO Hreflang Eran Incorrectas

Este es uno fácil de cometer. Google necesita etiquetas hreflang para entender la relación entre páginas localizadas. Nuestra implementación inicial carecía de la etiqueta x-default e tenía inconsistencias entre las etiquetas <link> y el mapa de sitio XML.

// Implementación correcta de hreflang
<Head>
  {locales.map(loc => (
    <link
      key={loc}
      rel="alternate"
      hrefLang={loc}
      href={`https://example.com/${loc}${path}`}
    />
  ))}
  <link rel="alternate" hrefLang="x-default" href={`https://example.com/en${path}`} />
</Head>

Error 4: Generación de Mapa de Sitio

Con 91K URLs, un archivo XML de mapa de sitio único no funcionará (el límite de Google es de 50,000 URLs por mapa de sitio). Necesitábamos un índice de mapa de sitio con múltiples mapas de sitio secundarios, divididos por locale:

<!-- sitemap-index.xml -->
<sitemapindex>
  <sitemap><loc>https://example.com/sitemaps/en.xml</loc></sitemap>
  <sitemap><loc>https://example.com/sitemaps/de.xml</loc></sitemap>
  <!-- ... 28 más -->
</sitemapindex>

Generamos estos usando next-sitemap con configuración personalizada, y se regeneran en cada compilación.

Cuándo Usar Este Stack (y Cuándo No)

Esta arquitectura -- Next.js + i18n + ISR en Vercel -- es poderosa, pero no es la opción correcta para todo.

Úsalo cuando:

  • Tengas 10+ locales con miles de páginas
  • Las actualizaciones de contenido sean frecuentes pero no en tiempo real
  • El rendimiento y Core Web Vitals importan para SEO
  • Tu equipo conoce bien React/Next.js

Considera alternativas cuando:

  • Tengas menos de 5 locales y menos de 1,000 páginas (SSG plano podría ser más simple)
  • El contenido sea verdaderamente en tiempo real (operaciones bursátiles, marcadores en vivo) -- usa SSR o obtención del lado del cliente
  • Estés limitado en presupuesto de hosting -- considera Astro para sitios multilingües puramente estáticos a una fracción del costo
  • Tu equipo sea pequeño y no necesite la interactividad de React -- un generador de sitios estáticos con i18n podría ser menos que mantener

Para equipos considerando un proyecto como este, hemos ayudado a varios clientes empresariales a arquitectar y construir aplicaciones Next.js a gran escala. Las decisiones arquitectónicas en las primeras dos semanas determinan si el proyecto tiene éxito o se convierte en una pesadilla de mantenimiento. Si quieres discutir tu situación específica, ponte en contacto.

Preguntas Frecuentes

¿Cómo funciona el enrutamiento i18n de Next.js con ISR? El enrutamiento i18n de Next.js agrega prefijos de locale a las URLs (como /fr/products/shoes). Cuando se combina con ISR, cada combinación de locale + página se almacena en caché de forma independiente en el borde de Vercel. Entonces /en/products/shoes y /fr/products/shoes son entradas de caché separadas, cada una con su propio temporizador de revalidación. La función getStaticProps recibe el locale en su contexto, y obtienes las traducciones y contenido localizado apropiados allí.

¿Cuál es el número máximo de páginas que ISR de Next.js puede manejar en Vercel? No hay límite técnico duro en el número de páginas ISR que Vercel puede servir. Hemos ejecutado 91K+ páginas con éxito, y he oído hablar de proyectos con 500K+ páginas. Los límites prácticos son el tiempo de compilación (para páginas pre-generadas), el rendimiento de revalidación y el costo. El caché de borde de Vercel está diseñado para esta escala -- es esencialmente una CDN con invalidación inteligente.

¿Afecta ISR a SEO para sitios multilingües? No, las páginas de ISR se renderizar completamente en HTML cuando se sirven desde caché, que es lo que ven los rastreadores de motor de búsqueda. Las consideraciones clave de SEO son etiquetas hreflang apropiadas, un índice de mapa de sitio bien estructurado con mapas de sitio por locale, y asegurarse de que tu configuración fallback: 'blocking' evita que los rastreadores vean páginas incompletas. Google ha confirmado que las páginas de ISR/en caché se tratan igual que el HTML estático tradicional.

¿Cómo manejas las actualizaciones de traducción sin redeplegar? Para contenido gestionado por CMS (descripciones de productos, copias de marketing), las traducciones se actualizan automáticamente a través de revalidación de ISR -- ya sea en el temporizador o a través de webhooks de revalidación bajo demanda. Para traducciones de cadena de UI (etiquetas de botones, mensajes de validación de formularios), se agrupan en tiempo de compilación, por lo que requieren un redepliegue. Los mantenemos separados intencionalmente: los cambios de contenido nunca deben requerir un despliegue, pero los cambios de cadena de UI pasan por revisión de código.

¿Cuál es la diferencia de costo entre ISR y SSR para sitios multilingües en Vercel? SSR ejecuta una función serverless en cada solicitud única. Con 2M de vistas de página/mes, eso son 2M de invocaciones de función a aproximadamente $0.40 por millón después de la capa gratuita -- aproximadamente $800/mes solo en costos de función, más ancho de banda significativamente más alto ya que hay menos almacenamiento en caché. Nuestra configuración de ISR costó aproximadamente $715/mes en total, mientras que SSR equivalente habría sido $2,500-3,500/mes.

¿Cómo manejas diferentes formatos de fecha, número y moneda en 30 locales? Usamos la API integrada Intl del navegador a través de utilidades de formato de next-intl. Esto maneja el formato de fecha (Intl.DateTimeFormat), formato de número (Intl.NumberFormat), y visualización de moneda correctamente para cada locale. El formato de mensaje ICU te permite incrustar estos formateadores directamente en cadenas de traducción: "price": "From {amount, number, ::currency/EUR}". Esto funciona del lado del servidor durante la generación de ISR y del lado del cliente para valores dinámicos.

¿Debo usar App Router o Pages Router para i18n a gran escala? A partir de Next.js 15 (mediados de 2025), la historia de i18n del App Router ha madurado significativamente, y next-intl v4 tiene soporte excelente del App Router. Para proyectos nuevos, recomendaría App Router. Ofrece mejor transmisión, React Server Components (que reducen el JavaScript del lado del cliente), y controles de almacenamiento en caché más granulares. Nuestro proyecto usó Pages Router porque se inició en 2024 cuando el soporte de i18n de App Router era menos estable, pero un proyecto greenfield hoy debería ir con App Router.

¿Qué sucede si la revalidación de ISR falla? ¿Ven los usuarios una página de error? No, y esta es una de las mejores características de ISR. Si la revalidación falla (tal vez la API del CMS está caída, u hay un error de código en getStaticProps), Vercel continúa sirviendo la última versión generada exitosamente de la página. Los usuarios nunca ven un error -- simplemente ven contenido ligeramente antiguo. La revalidación fallida se registra, y el siguiente intento de revalidación lo intentará nuevamente. Esto hace que ISR sea increíblemente resiliente en comparación con SSR, donde una interrupción de API se convierte inmediatamente en una interrupción frente al usuario.