Autor:
    Creación:2026-09-13Última actualización:2026-09-22

    next-intl VS @intlayer/next-intl: Misma API, Diferentes Bundle

    next-intl VS Intlayer

    @intlayer/next-intl es un adaptador de compatibilidad: expone la API de next-intl (useTranslations, getTranslations, useLocale, t.rich(), plurales ICU, NextIntlClientProvider...) y la sirve desde diccionarios compilados por Intlayer. El código de la aplicación no cambia. El bundle sí.

    Este artículo compara los dos en la misma aplicación Next.js, construida una vez con next-intl y otra con el adaptador. Los números provienen de Benchmark Bloom, una suite open-source que registra lo que el navegador realmente descarga. Si quieres la comparación de next-intl vs Intlayer como librerías, lee next-intl vs Intlayer. Este es sobre lo que cambia el adaptador cuando mantienes tus componentes como están.

    tl;dr: En la misma aplicación Next.js, cambiar next-intl por @intlayer/next-intl redujo el JavaScript por página de 153.6 KB a 147.5 KB gzip, el componente promedio de 21.8 KB a 8.1 KB, la fuga de cadenas de página extranjera de ~90% a 0%, e hidratación de 14.7 ms a 12.8 ms, sin editar ningún componente. En TanStack Start, el equivalente use-intl (@intlayer/use-intl) redujo los componentes de 76-87 KB a 9-11 KB y el cambio de locale de 7-21 ms a 4-9 ms. El adaptador cuesta 8.0 KB de runtime versus 14.7 KB para next-intl y 5.5 KB para next-intlayer nativo. La navegación y el middleware se reimplementan en la configuración de enrutamiento de Intlayer; las pathnames localizadas son la única característica que no se transfiere.

    Qué es @intlayer/next-intl

    next-intl es un runtime: getRequestConfig carga un messages/{locale}.json por solicitud, NextIntlClientProvider lo envía al cliente, y useTranslations("about") lee claves de ese objeto en tiempo de renderizado. Cada optimización (namespaces, pick(messages, [...]) por página, carga perezosa) depende de ti escribirla.

    @intlayer/next-intl mantiene la primera y la última parte de esa cadena y reemplaza la del medio. Tus componentes siguen llamando a useTranslations("about"); lo que reciben proviene de un diccionario Intlayer compilado en tiempo de compilación, limitado a ese componente, solo en la locale activa.

    Tres mecanismos hacen que funcione:

    1. Aliasing de importaciones. createNextIntlPlugin() desde @intlayer/next-intl/plugin envuelve withIntlayer y añade alias de Webpack / Turbopack para que next-intl, next-intl/server, next-intl/navigation y next-intl/middleware se resuelvan a @intlayer/next-intl. Ninguna importación en tu codebase es renombrada.
    2. JSON como fuente de verdad. El plugin syncJSON lee tu messages/{locale}.json existente, divide sus claves de nivel superior en un diccionario por namespace, y escribe las traducciones de vuelta en los mismos archivos cuando el CLI o el CMS las actualiza. El flujo de trabajo de tus traductores permanece intacto.
    3. Enlace en el sitio de llamada. El paso de optimización de Intlayer (Babel o SWC) reescribe useTranslations("about") en una llamada que recibe el diccionario about directamente. El componente ya no accede a un árbol de mensajes global; accede a su propio contenido.
    app/[locale]/about/page.tsx
    // Tu código, sin cambios
    import { useTranslations } from "next-intl";
    
    const AboutPage = () => {
      const t = useTranslations("about");
      return <h1>{t("title")}</h1>;
    };
    
    Lo que el compilador emite (simplificado)
    import _dicHash_about from "../.intlayer/dictionaries/about.mjs";
    import { useDictionary as useTranslations } from "@intlayer/next-intl";
    
    const AboutPage = () => {
      const t = useTranslations(_dicHash_about);
      return <h1>{t("title")}</h1>;
    };
    

    Esa reescritura es la razón por la cual las columnas de tamaño de componente y fuga de página se mueven a continuación: una página solo extrae los diccionarios de los componentes que renderiza, y solo en la configuración regional que se sirve.

    Lo que el adaptador mantiene, ignora y no reemplaza

    API de next-intlCon @intlayer/next-intl
    useTranslations("ns") / getTranslations("ns")✅ Mantenido. Vinculado al diccionario ns en tiempo de compilación. Las claves están tipificadas contra tu contenido.
    getTranslations({ locale, namespace })✅ Mantenido
    t("key", { name }), t.rich(), t.markup(), t.raw()✅ Mantenido. Los plurales ICU, select, selectordinal, #, {ts, date, long} se procesan a través del resolvedor ICU de Intlayer
    useLocale() / getLocale() / setRequestLocale() / setLocale✅ Mantenido
    useFormatter()✅ Mantenido. dateTime, number, relativeTime, list, dateTimeRange vinculan a Intl nativo
    NextIntlClientProvider✅ Mantenido. Los props messages, timeZone y now son aceptados pero ignorados (una advertencia de dev te lo indica)
    getMessages()✅ Mantenido por compatibilidad; ya no es necesario
    getRequestConfig() en src/i18n.ts⚠️ No necesario. Los diccionarios se compilan en tiempo de build; no hay carga de mensajes por solicitud
    defineRouting()✅ Mantenido. Los campos omitidos (locales, defaultLocale, localePrefix) se leen desde intlayer.config.ts
    createNavigation(), Link, redirect, usePathname, useRouter✅ Mantenido. Re-implementado en la configuración de enrutamiento de Intlayer; el argumento routing se acepta pero se ignora
    pathnames (nombres de rutas localizadas)❌ Aceptado para tipado, no interpolado. Mantén rutas simples o mueve ese mapeo a rewrite de Intlayer
    createMiddleware()✅ Mantenido. Devuelve el proxy de Intlayer; establece la cookie NEXT_LOCALE para que useLocale() y tu selector sigan funcionando
    NEXT_LOCALE cookie✅ Leído por defecto (a menos que configures routing.storage tú mismo)
    Bare useTranslations() con sin namespace⚠️ Funciona, pero el sitio de llamada no está vinculado: se resuelve a través del registro en tiempo de ejecución. Pasa un namespace para obtener las ganancias del bundle

    El benchmark

    Qué se midió

    La suite Benchmark Bloom construye la misma aplicación con cada configuración: 10 páginas (home, about, blog, careers, contact, FAQ, pricing, products, settings, team), 10 locales (en, fr, es, de, it, pt, zh, ja, ko, ru), componentes idénticos e contenido idéntico. Las páginas se miden en en y fr.

    next-intl fue construido en cuatro estrategias de carga, desde la configuración ingenua (messages/{locale}.json cargado completo) hasta la óptima (un namespace por ruta + pick() por página). El adaptador fue construido en los mismos componentes que la configuración ingenua, con solo next.config.ts e intlayer.config.ts modificados. No tiene una variante "scoped": el compilador realiza el scoping del contenido por componente, por lo que sus filas static y dynamic ya están scoped.

    Para cada compilación, la suite registra:

    • Lib size: tamaño gzip de un componente vacío que solo importa la librería i18n. El costo fijo del runtime.
    • Page JS: JavaScript gzip descargado por página, promediado en todas las páginas y locales.
    • Locale leak %: porcentaje de strings traducidos encontrados en el JavaScript descargado que pertenecen a una locale que el usuario no está visualizando.
    • Page leak %: porcentaje de strings traducidos encontrados en el JavaScript descargado que pertenecen a una página en la que el usuario no está.
    • Component avg: tamaño gzip promedio de cada componente compilado de forma aislada. Muestra cuánto runtime de i18n y catálogo arrastra un componente individual.
    • E2E reactivity: tiempo de reloj entre la selección de una nueva locale y la actualización de html[lang] en el DOM (Playwright, 5 iteraciones).
    • Hydration: duración de la fase de hidratación de React.
    Los números a continuación provienen de la ejecución del 2026-09-12 con next-intl / use-intl 4.14.2 y @intlayer/* 9.5.1. La aplicación de prueba es deliberadamente pequeña (algunas docenas de strings por locale), por lo que los porcentajes de fuga describen un patrón: crecen con tu contenido mientras que el costo del runtime se mantiene fijo.

    Resultados en Next.js

    Selecciona las métricas y las bibliotecas que te interesen:

    Métrica

    Carga JSON dinámica

    Carga traducciones en tiempo de ejecución

    JSON con alcance (namespacing)

    Espacios de nombres de traduction por página

    ¿Qué es esta métrica?

    El tamaño total comprimido en gzip del paquete de la biblioteca de internacionalización. Solo incluye el proveedor y la lógica de recuperación de contenido después del tree-shaking y la minificación.

    ¿Por qué es importante?

    Un tamaño de biblioteca más pequeño reduce la carga útil inicial de JavaScript, lo que acelera el tiempo de descarga y ejecución en el cliente.

    Ver como

    SetupStrategyLib size (gz)Page JS avg (gz)Locale leakPage leakComponent avg (gz)E2E reactivityHydration
    base (no i18n)-0.0 KB141.0 KB0.0%0.0%0.9 KB13.4 ms11.8 ms
    next-intlstatic14.7 KB153.6 KB4.2%89.8%21.8 KB16.0 ms14.7 ms
    next-intldynamic14.7 KB153.6 KB9.7%89.9%21.8 KB15.6 ms14.8 ms
    next-intlscoped-static14.7 KB153.6 KB0.0%0.0%80.1 KB17.9 ms17.4 ms
    next-intlscoped-dynamic14.7 KB153.6 KB0.0%0.0%22.9 KB17.8 ms16.8 ms
    @intlayer/next-intlstatic8.0 KB147.5 KB0.0%0.0%8.1 KB14.5 ms12.8 ms
    @intlayer/next-intldynamic8.0 KB148.7 KB0.0%0.0%8.1 KB11.7 ms12.8 ms
    next-intlayer (native)static5.5 KB141.3 KB0.0%0.0%8.5 KB15.5 ms16.9 ms
    next-intlayer (native)dynamic5.5 KB141.3 KB0.0%0.0%6.9 KB15.3 ms15.9 ms

    Cómo leerlo

    • Los mismos componentes, 6 KB menos por página. La compilación del adaptador de la aplicación ingenua llega a 147.5 KB, por debajo de cada configuración de next-intl incluyendo la completamente optimizada (153.6 KB). El tiempo de ejecución en sí es la diferencia: 8.0 KB versus 14.7 KB, pagado en cada página.
    • La fuga llega al 0% sin tocar un componente. La configuración ingenua de next-intl envía ~90% de strings de páginas extranjeras en cada página. Alcanzar 0% con next-intl significa las configuraciones scoped-*: un namespace por ruta, y pick(messages, [...]) en cada página. El adaptador llega al 0% desde el código ingenuo porque la pasada de optimización vincula cada useTranslations("ns") a su propio diccionario.
    • Los componentes se reducen 2.7x. Un componente compilado en aislamiento promedia 21.8 KB con next-intl (llega al proveedor y al árbol de mensajes) y 8.1 KB con el adaptador. En la configuración scoped-static de next-intl ese número sube a 80 KB, porque cada archivo de namespace de la ruta se vuelve accesible desde la página que lo selecciona.
    • La hidratación es 2 ms más rápida (12.8 vs 14.7 ms): no hay un objeto de mensaje que deserializar de la carga útil de RSC antes de que React pueda hidratarse.
    • El adaptador no es el runtime nativo. next-intlayer se ubica en 141.3 KB, +0.3 KB sobre la aplicación base, con un runtime de 5.5 KB. El adaptador lleva la superficie de la API de next-intl (useFormatter, t.rich, el resolutor de ICU) sobre el núcleo de Intlayer, por lo que consume 8.0 KB y +6 KB por página. Es el puente, no el destino.
    Tabla completa, cada biblioteca y cada estrategia, en el informe de benchmark de Next.js.

    Resultados en TanStack Start (use-intl)

    use-intl es el núcleo agnóstico del framework de next-intl. Su adaptador, @intlayer/use-intl, sigue el mismo diseño con un plugin de Vite (@intlayer/use-intl/plugin).

    ConfiguraciónEstrategiaTamaño lib (gz)Promedio JS página (gz)Fuga de localeFuga de páginaPromedio componente (gz)Reactividad E2EHidratación
    base (sin i18n)-0.0 KB111.0 KB0.0%0.0%0.7 KB8.1 ms21.6 ms
    use-intlstatic14.1 KB179.8 KB50.0%89.8%76.0 KB6.7 ms15.3 ms
    use-intldynamic14.1 KB119.4 KB0.0%89.8%75.9 KB7.0 ms15.4 ms
    use-intlscoped-static14.1 KB128.7 KB0.0%0.0%87.1 KB20.9 ms24.8 ms
    use-intlscoped-dynamic14.1 KB128.7 KB0.0%0.0%87.1 KB13.3 ms25.9 ms
    @intlayer/use-intlstatic7.3 KB135.8 KB49.7%0.0%10.9 KB4.2 ms10.5 ms
    @intlayer/use-intldynamic7.3 KB129.7 KB0.0%0.0%9.3 KB8.7 ms16.1 ms
    intlayer (native)static5.0 KB125.8 KB50.0%0.0%8.1 KB3.2 ms11.5 ms
    intlayer (native)dynamic5.0 KB118.6 KB0.0%0.0%6.3 KB3.6 ms14.1 ms

    Cómo leerlo

    • Los bytes por página son equivalentes al use-intl optimizado. @intlayer/use-intl en modo dynamic (129.7 KB) está dentro de 1 KB del scoped-dynamic (128.7 KB) de use-intl, y 10 KB por encima del dynamic simple (119.4 KB) de use-intl. Esa fila dynamic simple sigue filtrando el 90% de cadenas de páginas extranjeras; el recuento de bytes es bajo porque el contenido de la aplicación de prueba es pequeño. El 0% del adaptador es lo que se mantiene plano a medida que crece el contenido.
    • Los componentes son 7-9 veces más pequeños. Los componentes use-intl promedian 76-87 KB en cada estrategia, porque useTranslations está vinculado al objeto de mensaje completo del proveedor. El adaptador promedia 9-11 KB.
    • El cambio de locale es más rápido. Las configuraciones optimizadas de use-intl tardan 13-21 ms en actualizar html[lang]; el adaptador tarda 4-9 ms. Menos componentes se re-renderizan, y nada se vuelve a seleccionar de un árbol de mensajes.
    • static mantiene cada locale. La fila static del adaptador muestra 49.7% de fuga de locale, lo mismo que Intlayer nativo en modo static: todos los locales se empaquetan, solo se incluyen los diccionarios de la página. Una línea de configuración (importMode: 'dynamic') lo elimina.
    Tabla completa en el informe de benchmark de TanStack Start.

    Por qué los números cambian

    The Intlayer compiler extracts content from components

    Nada en el componente cambió, por lo que las ganancias provienen completamente de lo que useTranslations está vinculado.

    Con next-intl, el binding es el provider. NextIntlClientProvider recibe todo el objeto messages para la locale; cada useTranslations("about") lee desde él. El bundler ve un componente importando un hook que lee un contexto, y no puede saber que solo se usa la rama about. Las rutas de abajo comparten el mismo objeto de mensajes, por lo que la columna page-leak lee ~90% hasta que dividas el archivo tú mismo, y el desperdicio crece en dos ejes a la vez, páginas e idiomas:

    Theoretical content leakage by architecture

    bash
    .
    ├── messages
    │   ├── en.json                       # cada namespace, cada página
    │   └── fr.json
    └── src
        ├── i18n.ts                       # getRequestConfig({ messages: await import(...) })
        ├── middleware.ts                 # createMiddleware(routing)
        └── app/[locale]
            ├── layout.tsx                # <NextIntlClientProvider messages={messages}>
            └── about/page.tsx            # useTranslations("about")
    

    Con @intlayer/next-intl, el binding es el diccionario. syncJSON convierte messages/en.json en un diccionario por clave de nivel superior; el compilador resuelve qué componente llama a useTranslations("about") y le pasa about directamente, en la locale activa, como una importación que el bundler puede rastrear y dividir.

    bash
    .
    ├── intlayer.config.ts                # syncJSON({ source: ({ locale }) => `./messages/${locale}.json` })
    ├── messages
    │   ├── en.json                       # sin cambios, aún la fuente de verdad
    │   └── fr.json
    ├── .intlayer/                        # generado: un diccionario por namespace, por locale
    └── src
        ├── middleware.ts                 # createMiddleware() ahora devuelve el proxy de Intlayer
        └── app/[locale]
            ├── layout.tsx                # <NextIntlClientProvider> (sin prop messages)
            └── about/page.tsx            # useTranslations("about")  ← sin cambios
    

    src/i18n.ts y la prop messages desaparecen. Todo lo demás es idéntico.

    Migración en tres pasos

    1. Instalar

      bash
      npx intlayer init --interactive
      

      El comando detecta next-intl e instala intlayer, next-intlayer, @intlayer/next-intl y @intlayer/sync-json-plugin. Mantén next-intl instalado: es una dependencia peer del adaptador y proporciona los tipos.

    2. Apunta Intlayer a tus mensajes

      intlayer.config.ts
      import { Locales, type IntlayerConfig } from "intlayer";
      import { syncJSON } from "@intlayer/sync-json-plugin";
      
      const config: IntlayerConfig = {
        internationalization: {
          locales: [Locales.ENGLISH, Locales.FRENCH, Locales.SPANISH],
          defaultLocale: Locales.ENGLISH,
        },
        dictionary: {
          // "static" agrupa cada locale; "dynamic" carga la activa bajo demanda
          importMode: "dynamic",
        },
        plugins: [
          syncJSON({
            // Placeholders ICU: {name}, {count, plural, one {# item} other {# items}}
            format: "icu",
            source: ({ locale }) => `./messages/${locale}.json`,
            location: "messages",
          }),
        ],
      };
      
      export default config;
      

      messages/{locale}.json permanece donde está. Cada clave de nivel superior se convierte en un diccionario; useTranslations("about") se asigna al diccionario about.

    3. Envuelve next.config.ts

      next.config.ts
      import type { NextConfig } from "next";
      import { createNextIntlPlugin } from "@intlayer/next-intl/plugin";
      
      const withIntlayer = createNextIntlPlugin();
      
      const nextConfig: NextConfig = {};
      
      export default withIntlayer(nextConfig);
      

      createNextIntlPlugin() compone withIntlayer (observación de contenido, compilación de diccionarios, el paso de optimización) y los aliases de next-intl → @intlayer/next-intl para Webpack y Turbopack. Compila, y los números en las tablas anteriores son los tuyos.

    Qué puedes eliminar después

    Archivo / patrónPor qué
    getRequestConfig en src/i18n.tsSin carga de mensajes por solicitud. Mantén el archivo solo si también exporta helpers de createNavigation
    messages={...} en NextIntlClientProviderEl adaptador lee la salida compilada; la prop se ignora y registra una advertencia en desarrollo
    await getMessages() en layoutsMismo motivo
    Per-page pick(messages, [...])El compilador hace el picking, por componente

    Lo que ganas más allá de bytes

    • Typed keys. useTranslations("about") está tipado contra el diccionario about compilado. t("does.not.exist") es un error de TypeScript, no un fallback en tiempo de ejecución.
    • npx intlayer test falla en CI cuando a una locale le falta una clave. npx intlayer fill traduce las que faltan con el proveedor de tu elección (OpenAI, Anthropic, Mistral, Gemini...) usando tu propia clave, y escribe el resultado de vuelta en messages/{locale}.json.
    • Visual Editor y CMS funcionan en los mismos diccionarios, así que los no desarrolladores pueden editar messages/fr.json a través de una interfaz y el archivo se actualiza.
    • Migración incremental a .content.ts. Cualquier componente puede cambiar de useTranslations("about") a useIntlayer("about") con un archivo de contenido colocado junto al componente, uno a la vez. Los diccionarios JSON y .content.ts coexisten y se fusionan.

    Límites a conocer antes de comenzar

    createNavigation(routing) y createMiddleware(routing) mantienen su firma pero ignoran el argumento: los idiomas, el idioma predeterminado y la estrategia de prefijo provienen de la configuración routing de Intlayer. Si utilizas los pathnames localizados de next-intl (/about a /a-propos), el adaptador no los interpola; routing.rewrite de Intlayer cubre ese caso pero es un cambio separado.

    El paso de optimización necesita un namespace estático para saber qué diccionario importar. Una llamada sin namespace sigue funcionando a través de un registro de runtime que referencia cada diccionario, lo cual es exactamente la fuga que intentabas eliminar. Pasa el namespace.

    8.0 KB de runtime frente a 5.5 KB de next-intlayer, y +6-7 KB por página sobre la compilación nativa. Paga por la superficie de la API de next-intl. Si llegas al punto en que cada componente se ha migrado a useIntlayer, elimina el adaptador.

    Los formateadores están respaldados por la API nativa Intl y solo el idioma influye en su salida. Si dependes de una zona horaria forzada o un now fijo para fechas estables en hidratación, manéjalo en el punto de llamada. Consulta formateo de fechas, horas y números.

    ¿Cuándo usar cuál?

    Tu aplicación es pequeña, el tamaño del bundle no es una preocupación y tu equipo se siente cómodo gestionando namespaces y pick() por página.

    Hoy usas next-intl y quieres las ventajas de bundle, reducción de fugas e hidratación rápida, claves tipadas y las herramientas de CLI / CMS sin una reescritura. Este es el punto de entrada recomendado para cualquier base de código next-intl existente.

    Para nuevos proyectos, o una vez que el adaptador haya cumplido su función. Es el más ligero de los tres (5.5 KB, +0.3 KB por página) y desbloquea componentes de servidor síncronos, archivos .content.ts por componente y el conjunto completo de características. Comienza con Intlayer con Next.js.

    Preguntas frecuentes

    En Next.js, sí para los componentes: la compilación del benchmark solo modificó next.config.ts e intlayer.config.ts. getRequestConfig en src/i18n.ts, la prop messages en el provider y las llamadas pick() por página se convierten en código muerto que puedes eliminar después.

    Siguen funcionando. t("key", { count }), t.rich(), t.markup(), select, selectordinal, # y {ts, date, long} son resueltos por el intérprete ICU de Intlayer. Consulta formato de mensaje ICU.

    Transporta la superficie de la API de next-intl sobre el núcleo de Intlayer: useFormatter, t.rich, el intérprete ICU, los helpers de navegación. Eso representa 8.0 KB frente a 5.5 KB, y +6 KB por página. Es el puente, no el destino final.

    Sí. Cualquier componente puede cambiar de useTranslations("about") a useIntlayer("about") con un archivo .content.ts coubicado. Los diccionarios JSON y .content.ts coexisten y se combinan, por lo que no hay interrupciones.

    No a través de los pathnames de next-intl: el adaptador los acepta para tipado pero no los interpola. Utiliza en su lugar routing.rewrite de Intlayer, que emite los literales localizados en el registro de tipos.

    Comparaciones relacionadas

    Misma serie de adaptadores:

    Las bibliotecas comparadas directamente:

    Documentación de referencia:

    Para entender de dónde vienen estas bibliotecas, lee la historia del i18n en JavaScript.

    Conclusión

    @intlayer/next-intl hace una cosa: cambia a qué está vinculado useTranslations, de un proveedor que contiene cada mensaje a un diccionario compilado para ese componente. En la misma aplicación Next.js que vale 6 KB por página, componentes 2.7x más pequeños, 0% de fuga e hidratación de 2 ms, antes de que alguien abra un archivo de componente. La navegación y el middleware mantienen su API en la parte superior de la configuración de enrutamiento de Intlayer, y el runtime nativo next-intlayer sigue siendo más ligero.

    Todos los datos brutos, las aplicaciones de prueba y los scripts están en el repositorio Benchmark Bloom. Ejecútalo tú mismo.

    Consulta la documentación 'Why Intlayer?' para más detalles.

    Comentarios

    Aún no hay comentarios. Sé el primero en compartir tus pensamientos.

    Artículos relacionados

    Últimos artículos