Autore:
    Creazione:2026-09-13Ultimo aggiornamento:2026-09-13

    next-intl VS @intlayer/next-intl | Stessa API, Bundle Diverso

    @intlayer/next-intl è un compat adapter: espone l'API di next-intl (useTranslations, getTranslations, useLocale, t.rich(), ICU plurals, NextIntlClientProvider...) e la serve da dizionari compilati da Intlayer. Il codice dell'applicazione non cambia. Il bundle sì.

    Questo articolo confronta i due sulla stessa applicazione Next.js, costruita una volta con next-intl e una volta con l'adapter. I numeri provengono da Benchmark Bloom, una suite open-source che registra ciò che il browser effettivamente scarica. Se vuoi il confronto next-intl vs Intlayer come librerie, leggi next-intl vs Intlayer. Questo riguarda ciò che l'adapter cambia quando mantieni i tuoi componenti come sono.

    tl;dr: Sulla stessa app Next.js, sostituire next-intl con @intlayer/next-intl ha ridotto il JavaScript per pagina da 153.6 KB a 147.5 KB gzip, il componente medio da 21.8 KB a 8.1 KB, la perdita di stringhe da pagine estere da ~90% a 0%, e l'idratazione da 14.7 ms a 12.8 ms, senza modificare alcun componente. Su TanStack Start, l'equivalente use-intl (@intlayer/use-intl) ha ridotto i componenti da 76-87 KB a 9-11 KB e il cambio di locale da 7-21 ms a 4-9 ms. L'adapter costa 8.0 KB di runtime rispetto ai 14.7 KB di next-intl e ai 5.5 KB di next-intlayer nativo. La navigazione e il middleware vengono reimplementati sulla configurazione di routing di Intlayer; i pathnames localizzati sono l'unica feature non trasferita.

    Cos'è @intlayer/next-intl

    next-intl è un runtime: getRequestConfig carica un messages/{locale}.json per richiesta, NextIntlClientProvider lo invia al client, e useTranslations("about") legge le chiavi da quell'oggetto al momento del render. Ogni ottimizzazione (namespace, pick(messages, [...]) per pagina, lazy loading) è a tuo carico.

    @intlayer/next-intl mantiene la prima e l'ultima parte di quella catena e sostituisce quella centrale. I tuoi componenti continuano a chiamare useTranslations("about"; quello che ricevono proviene da un dizionario Intlayer compilato al momento della build, limitato a quel componente, solo nella locale attiva.

    Tre meccanismi lo rendono possibile:

    1. Import aliasing. createNextIntlPlugin() da @intlayer/next-intl/plugin avvolge withIntlayer e aggiunge alias Webpack / Turbopack in modo che next-intl, next-intl/server, next-intl/navigation e next-intl/middleware si risolvano a @intlayer/next-intl. Nessun import nella tua codebase viene rinominato.
    2. JSON come fonte di verità. Il plugin syncJSON legge il tuo messages/{locale}.json esistente, divide le sue chiavi di primo livello in un dizionario per namespace, e scrive le traduzioni negli stessi file quando CLI o CMS li aggiorna. Il workflow dei tuoi traduttori rimane invariato.
    3. Call-site binding. La pass di ottimizzazione di Intlayer (Babel o SWC) riscrive useTranslations("about") in una chiamata che riceve direttamente il dizionario about. Il componente non raggiunge più un albero di messaggi globale; raggiunge il suo stesso contenuto.
    app/[locale]/about/page.tsx
    // Il tuo codice, invariato
    import { useTranslations } from "next-intl";
    
    const AboutPage = () => {
      const t = useTranslations("about");
      return <h1>{t("title")}</h1>;
    };
    
    Ciò che il compilatore emette (semplificato)
    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>;
    };
    

    Questo riscrittura è il motivo per cui le colonne dimensioni dei componenti e page-leakage qui sotto si spostano: una pagina estrae solo i dizionari dei componenti che renderizza, e solo nella locale servita.

    Ciò che l'adapter mantiene, ignora e non sostituisce

    next-intl APICon @intlayer/next-intl
    useTranslations("ns") / getTranslations("ns")✅ Mantenuto. Vincolato al dizionario ns al build time. Le chiavi sono tipizzate rispetto ai tuoi contenuti.
    getTranslations({ locale, namespace })✅ Mantenuto
    t("key", { name }), t.rich(), t.markup(), t.raw()✅ Mantenuto. I plurali ICU, select, selectordinal, #, {ts, date, long} vengono elaborati tramite il resolver ICU di Intlayer
    useLocale() / getLocale() / setRequestLocale() / setLocale✅ Mantenuto
    useFormatter()✅ Mantenuto. dateTime, number, relativeTime, list, dateTimeRange si collegano alle API native Intl
    NextIntlClientProvider✅ Mantenuto. Le props messages, timeZone e now sono accettate ma ignorate (un avviso di sviluppo te lo comunica)
    getMessages()✅ Mantenuto per compatibilità; non più necessario
    getRequestConfig() in src/i18n.ts⚠️ Non necessario. I dizionari sono compilati al momento della build; non c'è caricamento di messaggi per-request
    defineRouting()✅ Mantenuto. I campi omessi (locales, defaultLocale, localePrefix) sono letti da intlayer.config.ts
    createNavigation(), Link, redirect, usePathname, useRouter✅ Mantenuto. Re-implementato sulla configurazione di routing di Intlayer; l'argomento routing è accettato ma ignorato
    pathnames (nomi di rotte localizzate)❌ Accettato per la tipizzazione, non interpolato. Mantieni i percorsi semplici o sposta quel mapping al rewrite di Intlayer
    createMiddleware()✅ Mantenuto. Restituisce il proxy di Intlayer; imposta il cookie NEXT_LOCALE in modo che useLocale() e il tuo switcher continuino a funzionare
    Cookie NEXT_LOCALE✅ Letto per impostazione predefinita (a meno che tu non configuri routing.storage da solo)
    Bare useTranslations() con nessuno namespace⚠️ Funziona, ma il sito di chiamata non è vincolato: si risolve attraverso il registro runtime. Passa un namespace per ottenere i guadagni di bundle

    Il benchmark

    Cosa è stato misurato

    La suite Benchmark Bloom costruisce la stessa applicazione con ogni setup: 10 pagine (home, about, blog, careers, contact, FAQ, pricing, products, settings, team), 10 locale (en, fr, es, de, it, pt, zh, ja, ko, ru), componenti identici e contenuto identico. Le pagine sono misurate in en e fr.

    next-intl è stato costruito con quattro strategie di caricamento, dalla configurazione ingenua (messages/{locale}.json caricato interamente) a quella ottimale (uno namespace per route + pick() per pagina). L'adapter è stato costruito sugli stessi componenti della configurazione ingenua, con solo next.config.ts e intlayer.config.ts modificati. Non ha una variante "scoped": il compiler scopa il contenuto per componente, quindi le righe static e dynamic sono già scoped.

    Per ogni build, la suite registra:

    • Lib size: dimensione gzip di un componente vuoto che importa solo la libreria i18n. Il costo fisso del runtime.
    • Page JS: JavaScript gzip scaricato per pagina, mediato su tutte le pagine e le locale.
    • Locale leak %: percentuale di stringhe tradotte trovate nel JS scaricato che appartengono a una locale che l'utente non sta visualizzando.
    • Page leak %: percentuale di stringhe tradotte trovate nel JS scaricato che appartengono a una pagina su cui l'utente non si trova.
    • Component avg: dimensione gzip media di ogni componente compilato isolatamente. Mostra quanto runtime i18n e catalogo un singolo componente comporta.
    • E2E reactivity: tempo trascorso tra la selezione di una nuova locale e l'aggiornamento di html[lang] nel DOM (Playwright, 5 iterazioni).
    • Hydration: durata della fase di hydration di React.
    I numeri sottostanti provengono dall'esecuzione datata 2026-09-12 con next-intl / use-intl 4.14.2 e @intlayer/* 9.5.1. L'applicazione di test è deliberatamente piccola (poche decine di stringhe per locale), quindi le percentuali di leakage descrivono un pattern: crescono con i tuoi contenuti mentre il costo del runtime rimane fisso.

    Risultati su Next.js

    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

    Come leggerlo

    • Stessi componenti, 6 KB in meno per pagina. La build dell'adapter dell'app naive atterra a 147.5 KB, al di sotto di ogni configurazione di next-intl inclusa quella completamente ottimizzata (153.6 KB). Il runtime stesso è la differenza: 8.0 KB versus 14.7 KB, pagati su ogni pagina.
    • La perdita di dati va a 0% senza toccare un componente. La configurazione naive di next-intl spedisce ~90% delle stringhe di pagine straniere su ogni pagina. Raggiungere 0% con next-intl significa le configurazioni scoped-*: uno namespace per route e pick(messages, [...]) in ogni pagina. L'adapter raggiunge 0% dal codice naive perché il pass di ottimizzazione lega ogni useTranslations("ns") al proprio dizionario.
    • I componenti si riducono 2.7x. Un componente compilato in isolamento media 21.8 KB con next-intl (raggiunge il provider e l'albero dei messaggi) e 8.1 KB con l'adapter. Nella configurazione scoped-static di next-intl quel numero sale fino a 80 KB, perché il file namespace di ogni route diventa raggiungibile dalla pagina che lo seleziona.
    • L'idratazione è 2 ms più veloce (12.8 vs 14.7 ms): non c'è nessun oggetto messaggio da deserializzare dal payload RSC prima che React possa idrare.
    • L'adapter non è il runtime nativo. next-intlayer si posiziona a 141.3 KB, +0.3 KB sopra l'app base, con un runtime di 5.5 KB. L'adapter trasporta la superficie API di next-intl (useFormatter, t.rich, il risolutore ICU) in cima al core di Intlayer, da cui 8.0 KB e +6 KB per pagina. È il ponte, non la destinazione.

    Risultati su TanStack Start (use-intl)

    use-intl è il core framework-agnostico di next-intl. Il suo adapter, @intlayer/use-intl, segue lo stesso design con un plugin Vite (@intlayer/use-intl/plugin).

    SetupStrategyLib size (gz)Page JS avg (gz)Locale leakPage leakComponent avg (gz)E2E reactivityHydration
    base (no 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

    Come leggerlo

    • I byte per pagina sono equivalenti rispetto a use-intl ottimizzato. @intlayer/use-intl in modalità dynamic (129.7 KB) è entro 1 KB da use-intl's scoped-dynamic (128.7 KB), e 10 KB più grande di use-intl's plain dynamic (119.4 KB). Quella riga dynamic plain ha ancora una perdita del 90% di stringhe da pagine estere; il conteggio dei byte è basso perché il contenuto dell'app di test è piccolo. Lo 0% dell'adapter è quello che rimane piatto man mano che il contenuto cresce.
    • I componenti sono 7-9 volte più piccoli. I componenti use-intl hanno una media di 76-87 KB in ogni strategia, perché useTranslations è associato all'intero oggetto messaggio del provider. L'adapter ha una media di 9-11 KB.
    • Lo switching delle locale è più veloce. Gli setup use-intl ottimizzati impiegano 13-21 ms per aggiornare html[lang]; l'adapter impiega 4-9 ms. Meno componenti vengono renderizzati di nuovo, e niente viene ripreso da un albero di messaggi.
    • static mantiene ogni locale. La riga static dell'adapter mostra una perdita di locale del 49,7%, la stessa di Intlayer nativo in modalità static: tutte le locale vengono raggruppate, solo i dizionari della pagina. Una riga di configurazione (importMode: 'dynamic') la rimuove.

    Perché i numeri cambiano

    Poiché niente nel componente è cambiato, i guadagni provengono interamente da ciò a cui useTranslations è associato.

    Con next-intl, il binding è il provider. NextIntlClientProvider riceve l'intero oggetto messages per la locale; ogni useTranslations("about") legge da esso. Il bundler vede un componente che importa un hook che legge un context, e non può sapere che solo il ramo about è utilizzato. Le route di seguito condividono tutti lo stesso oggetto message, quindi la colonna page-leak legge ~90% fino a quando non dividi il file tu stesso.

    bash
    .
    ├── messages
       ├── en.json                       # ogni namespace, ogni pagina
       └── 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, il binding è il dizionario. syncJSON trasforma messages/en.json in un dizionario per ogni chiave di primo livello; il compilatore risolve quale componente chiama useTranslations("about") e gli passa about direttamente, nella locale attiva, come un import che il bundler può tracciare e splittare.

    bash
    .
    ├── intlayer.config.ts                # syncJSON({ source: ({ locale }) => `./messages/${locale}.json` })
    ├── messages
       ├── en.json                       # unchanged, still the source of truth
       └── fr.json
    ├── .intlayer/                        # generated: one dictionary per namespace, per locale
    └── src
        ├── middleware.ts                 # createMiddleware() ora restituisce il proxy di Intlayer
        └── app/[locale]
            ├── layout.tsx                # <NextIntlClientProvider> (nessun prop messages)
            └── about/page.tsx            # useTranslations("about")  ← invariato
    

    src/i18n.ts e il prop messages scompaiono. Tutto il resto è identico.

    Migrazione in tre passaggi

    1. Installa

      bash
      npx intlayer init --interactive
      

      Il comando rileva next-intl e installa intlayer, next-intlayer, @intlayer/next-intl e @intlayer/sync-json-plugin. Mantieni next-intl installato: è una peer dependency dell'adapter e fornisce i types.

    2. Punta Intlayer ai tuoi messaggi

      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" raggruppa ogni locale; "dynamic" carica quella attiva su richiesta
          importMode: "dynamic",
        },
        plugins: [
          syncJSON({
            // Placeholder ICU: {name}, {count, plural, one {# item} other {# items}}
            format: "icu",
            source: ({ locale }) => `./messages/${locale}.json`,
            location: "messages",
          }),
        ],
      };
      
      export default config;
      

      messages/{locale}.json rimane dov'è. Ogni chiave di primo livello diventa un dictionary; useTranslations("about") viene mappato al dictionary about.

    3. Avvolgi 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 (content watching, compilazione del dizionario, il passaggio di ottimizzazione) e gli alias next-intl@intlayer/next-intl per Webpack e Turbopack. Compila, e i numeri nelle tabelle sopra sono tuoi.

    Cosa puoi eliminare successivamente

    File / patternPerché
    getRequestConfig in src/i18n.tsNessun caricamento di messaggi per-request. Mantieni il file solo se esporta anche gli helper createNavigation
    messages={...} su NextIntlClientProviderL'adapter legge l'output compilato; il prop viene ignorato e registra un avviso in sviluppo
    await getMessages() nei layoutStesso motivo
    Per-page pick(messages, [...])Il compiler fa il picking, per componente

    Cosa guadagni oltre ai byte

    • Typed keys. useTranslations("about") è tipizzato rispetto al dizionario compilato about. t("does.not.exist") è un errore TypeScript, non un fallback runtime.
    • npx intlayer test interrompe la CI quando a una locale manca una chiave. npx intlayer fill traduce le chiavi mancanti con il provider di tua scelta (OpenAI, Anthropic, Mistral, Gemini...) utilizzando la tua chiave personale e scrive il risultato in messages/{locale}.json.
    • Visual Editor e CMS funzionano sugli stessi dizionari, quindi gli sviluppatori non svuoti possono modificare messages/fr.json attraverso un'interfaccia utente e il file si aggiorna.
    • Migrazione incrementale a .content.ts. Qualsiasi componente può passare da useTranslations("about") a useIntlayer("about") con un file di contenuto colocato, uno alla volta. I dizionari JSON e .content.ts coesistono e si uniscono.

    Limiti da conoscere prima di iniziare

    • La configurazione del routing si sposta in intlayer.config.ts. createNavigation(routing) e createMiddleware(routing) mantengono la loro firma ma ignorano l'argomento: le locale, la locale predefinita e la strategia di prefisso provengono dalla configurazione routing di Intlayer. Se utilizzi i pathnames localizzati di next-intl (/about/a-propos), l'adapter non li interpola; routing.rewrite di Intlayer copre quel caso ma è una modifica separata.
    • useTranslations() senza namespace non è vincolato. Il pass di ottimizzazione ha bisogno di uno spazio dei nomi statico per sapere quale dizionario importare. Una chiamata nuda funziona ancora, attraverso un registro di runtime che fa riferimento a ogni dizionario, che è esattamente la perdita che stavi cercando di rimuovere. Passa il namespace.
    • L'adapter non è gratuito. 8.0 KB di runtime rispetto a 5.5 KB per next-intlayer, e +6-7 KB per pagina rispetto alla build nativa. Questo copre la superficie API di next-intl. Se raggiungi il punto in cui ogni componente è stato spostato a useIntlayer, elimina l'adapter.
    • messages, timeZone, now sul provider vengono ignorati. I formatter sono supportati da Intl nativo e solo la locale ne influenza l'output; se dipendi da un fuso orario forzato o da un now fisso per date stabili durante l'hydration, gestiscilo nel sito di chiamata.

    Quando usare quale?

    • Rimani su next-intl se la tua app è piccola, il tuo bundle non è una preoccupazione, e il tuo team è a suo agio nel gestire i namespace e pick() per pagina.
    • Usa @intlayer/next-intl se sei attualmente su next-intl e desideri i vantaggi di bundle, leakage e hydration, chiavi tipizzate e gli strumenti CLI / CMS senza una riscrittura. Questo è il punto di ingresso consigliato per qualsiasi codebase next-intl esistente.
    • Vai nativo (next-intlayer) per i nuovi progetti, o una volta che l'adapter ha svolto il suo lavoro. È il più leggero dei tre (5.5 KB, +0.3 KB per pagina) e sblocca i server component sincroni, i file .content.ts per componente e l'intero set di funzionalità.

    Confronti correlati

    Conclusione

    @intlayer/next-intl fa una cosa sola: cambia a cosa è associato useTranslations, da un provider che contiene ogni messaggio a un dizionario compilato per quel componente. Sulla stessa app Next.js che vale 6 KB per pagina, componenti 2,7x più piccoli, 0% leakage e 2 ms di hydration, prima che chiunque apra un file di componente. Navigation e middleware mantengono la loro API in cima alla configurazione di routing di Intlayer, e il runtime nativo next-intlayer rimane ancora più leggero.

    Tutti i dati grezzi, le app di test e gli script sono nel repository Benchmark Bloom. Eseguilo tu stesso.

    Fai riferimento al doc 'Why Intlayer?' per ulteriori dettagli.

    Commenti

    Ancora nessun commento. Sii il primo a condividere i tuoi pensieri.

    Articoli correlati

    Ultimi articoli