Автор:
    Дата створення:2026-09-13Останнє оновлення:2026-09-13

    next-intl VS @intlayer/next-intl | Один API, різні Bundle

    @intlayer/next-intl - це адаптер сумісності: він надає API next-intl (useTranslations, getTranslations, useLocale, t.rich(), ICU plurals, NextIntlClientProvider...) та обслуговує його з словників, скомпільованих Intlayer. Код застосунку не змінюється. Змінюється bundle.

    У цій статті порівнюються обидва на одному й тому ж Next.js застосунку, побудованому один раз з next-intl та один раз з адаптером. Цифри взяті з Benchmark Bloom, open-source набору, який записує, що насправді завантажує браузер. Якщо вам потрібне порівняння next-intl та Intlayer як бібліотек, прочитайте next-intl vs Intlayer. Ця стаття про те, як адаптер змінює все, коли ви зберігаєте компоненти як вони є.

    tl;dr: На тій же Next.js програмі заміна next-intl на @intlayer/next-intl зменшила JavaScript на сторінку з 153.6 KB до 147.5 KB gzip, середній компонент з 21.8 KB до 8.1 KB, витік рядків іноземних сторінок з ~90% до 0% та гідрацію з 14.7 ms до 12.8 ms, без редагування компонентів. На TanStack Start еквівалент use-intl (@intlayer/use-intl) зменшив компоненти з 76-87 KB до 9-11 KB та перемикання локалі з 7-21 ms до 4-9 ms. Адаптер коштує 8.0 KB runtime порівняно з 14.7 KB для next-intl та 5.5 KB для нативного next-intlayer. Навігація та middleware переімплементовані на конфігурації маршрутизації Intlayer; локалізовані pathnames - це одна функція, яка не перенесена.

    Що таке @intlayer/next-intl

    next-intl - це runtime: getRequestConfig завантажує messages/{locale}.json на запит, NextIntlClientProvider відправляє його на клієнт, а useTranslations("about") читає ключі з цього об'єкту під час рендерингу. Кожна оптимізація (namespaces, pick(messages, [...]) на сторінку, ліниве завантаження) - це ваша робота.

    @intlayer/next-intl зберігає першу та останню частину цього ланцюга й замінює середину. Ваші компоненти все ще викликають useTranslations("about"); те, що вони отримують, походить із словника Intlayer, скомпільованого під час збирання, обмеженого цією компонентою, лише в активній мові.

    Три механізми роблять це можливим:

    1. Import aliasing. createNextIntlPlugin() з @intlayer/next-intl/plugin обертає withIntlayer і додає aliases у Webpack / Turbopack, щоб next-intl, next-intl/server, next-intl/navigation та next-intl/middleware були перенаправлені на @intlayer/next-intl. Жоден import у вашій codebase не перейменовується.
    2. JSON як джерело істини. The syncJSON plugin читає ваш існуючий messages/{locale}.json, ділить його top-level ключі на один словник на кожний namespace, і записує переклади назад в ті самі файли, коли CLI або CMS оновлює їх. Робочий процес ваших перекладачів залишається незмінним.
    3. Call-site binding. The Intlayer optimize pass (Babel or SWC) rewrites useTranslations("about") into a call that receives the about dictionary directly. The component no longer reaches a global message tree; it reaches its own content.
    app/[locale]/about/page.tsx
    // Ваш код, без змін
    import { useTranslations } from "next-intl";
    
    const AboutPage = () => {
      const t = useTranslations("about");
      return <h1>{t("title")}</h1>;
    };
    
    What the compiler emits (simplified)
    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>;
    };
    

    Це переписування - причина, чому колонки розміру компонента та витоку сторінки нижче зміщуються: сторінка завантажує лише словники компонентів, які вона рендерує, і тільки на мові, яка подається.

    Що адаптер зберігає, ігнорує та не замінює

    next-intl APIЗ @intlayer/next-intl
    useTranslations("ns") / getTranslations("ns")✅ Збережено. Прив'язано до словника ns під час збірки. Ключи набиралися на основі вашого вмісту.
    getTranslations({ locale, namespace })✅ Збережено
    t("key", { name }), t.rich(), t.markup(), t.raw()✅ Збережено. ICU plurals, select, selectordinal, #, {ts, date, long} обробляються через ICU resolver Intlayer
    useLocale() / getLocale() / setRequestLocale() / setLocale✅ Збережено
    useFormatter()✅ Збережено. dateTime, number, relativeTime, list, dateTimeRange підтримують нативний Intl
    NextIntlClientProvider✅ Збережено. Props messages, timeZone та now приймаються, але ігноруються (девелопер отримує попередження)
    getMessages()✅ Збережено для сумісності; більше не потрібен
    getRequestConfig() у src/i18n.ts⚠️ Не потрібен. Словники компілюються під час збірки; немає завантаження повідомлень для кожного запиту
    defineRouting()✅ Збережено. Пропущені поля (locales, defaultLocale, localePrefix) читаються з intlayer.config.ts
    createNavigation(), Link, redirect, usePathname, useRouter✅ Збережено. Перереалізовано на базі конфігурації маршрутизації Intlayer; аргумент routing приймається, але ігнорується
    pathnames (локалізовані імена маршрутів)❌ Приймається для типізації, не інтерпольовується. Зберігайте звичайні імена маршрутів або перенесіть це відображення на Intlayer's rewrite
    createMiddleware()✅ Збережено. Повертає проксі Intlayer; встановлює cookie NEXT_LOCALE, щоб useLocale() та ваш перемикач продовжували працювати
    NEXT_LOCALE cookie✅ Читається за замовчуванням (якщо ви не налаштуєте routing.storage самостійно)
    Простий useTranslations() без namespace⚠️ Працює, але місце виклику не пов'язане: розв'язується через реєстр runtime. Передайте namespace для отримання переваг bundle

    Бенчмарк

    Що було вимірено

    Набір Benchmark Bloom створює одну й ту саму програму з кожною конфігурацією: 10 сторінок (home, about, blog, careers, contact, FAQ, pricing, products, settings, team), 10 локалей (en, fr, es, de, it, pt, zh, ja, ko, ru), однакові компоненти та однаковий вміст. Сторінки вимірюються у en та fr.

    next-intl було створено з чотирма стратегіями завантаження, від наївної конфігурації (messages/{locale}.json завантажується повністю) до оптимальної (one namespace per route + per-page pick()). Адаптер був створений на тих же компонентах, що й наівна конфігурація, з тільки змінами в next.config.ts та intlayer.config.ts. Він не має варіанту "scoped": компілятор охоплює контент на рівні компонента, тому його рядки static та dynamic вже охоплені.

    Для кожної збірки набір тестів записує:

    • Lib size: gzip розмір порожнього компонента, який лише імпортує бібліотеку i18n. Фіксована вартість runtime.
    • Page JS: gzip JavaScript, завантажений на сторінку, усереднений по всіх сторінках і локалях.
    • Locale leak %: частка перекладених рядків, знайдених у завантаженому JS, які належать мові, яку користувач не переглядає.
    • Page leak %: частка перекладених рядків, знайдених у завантаженому JS, які належать сторінці, на якій користувач не знаходиться.
    • Component avg: середній розмір gzip кожного компонента, скомпільованого окремо. Показує, скільки i18n runtime та каталогу витягує один компонент.
    • E2E reactivity: час від моменту вибору нової мови до оновлення html[lang] у DOM (Playwright, 5 ітерацій).
    • Hydration: тривалість фази React hydration.
    Числа нижче отримані з запуску від 2026-09-12 з next-intl / use-intl 4.14.2 та @intlayer/* 9.5.1. Тестовий застосунок навмисне малий (кілька десятків рядків на кожну мову), тому відсотки витоку описують закономірність: вони зростають з вашим контентом, тоді як витрати runtime залишаються постійними.

    Результати на 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

    Як це читати

    • Ті самі компоненти, на 6 KB менше на сторінку. Адаптер build наївної програми приходить до 147.5 KB, менше ніж будь-яка конфігурація next-intl, включаючи повністю оптимізовану (153.6 KB). Сам runtime є різницею: 8.0 KB проти 14.7 KB, сплачується на кожній сторінці.
    • Витік коду йде до 0% без змін компонентів. Наївне налаштування next-intl поставляє ~90% рядків іноземних сторінок на кожну сторінку. Досягнення 0% за допомогою next-intl означає налаштування scoped-*: один простір імен на маршрут і pick(messages, [...]) на кожній сторінці. Адаптер досягає 0% з наївного коду, оскільки крок оптимізації прив'язує кожен useTranslations("ns") до свого словника.
    • Компоненти зменшуються в 2,7 разу. Компонент, скомпільований окремо, в середньому становить 21,8 KB з next-intl (він досягає провайдера та дерева повідомлень) і 8,1 KB з адаптером. У налаштуванні scoped-static next-intl це число зростає до 80 KB, оскільки файл простору імен кожного маршруту стає доступним зі сторінки, яка його вибирає.
    • Гідрація на 2 мс швидша (12.8 vs 14.7 мс): немає об'єкта повідомлень для десеріалізації з RSC payload перед тим, як React може гідрувати.
    • Адаптер - це не native runtime. next-intlayer займає 141.3 KB, +0.3 KB понад базову програму, з 5.5 KB runtime. Адаптер несе поверхню API next-intl (useFormatter, t.rich, ICU resolver) поверх ядра Intlayer, звідси 8.0 KB і +6 KB за сторінку. Це міст, а не пункт призначення.

    Результати на TanStack Start (use-intl)

    use-intl - це framework-агностичне ядро next-intl. Його адаптер @intlayer/use-intl слідує тому ж дизайну з Vite плагіном (@intlayer/use-intl/plugin).

    НалаштуванняСтратегіяРозмір Lib (gz)Page JS середньо (gz)Витік локаліPage витікComponent середньо (gz)E2E реактивністьГідратація
    base (без 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

    Як читати це

    • Байти на сторінку практично однакові з оптимізованим use-intl. @intlayer/use-intl у режимі dynamic (129.7 KB) знаходиться в межах 1 KB від use-intl's scoped-dynamic (128.7 KB) і на 10 KB вище звичайного dynamic від use-intl (119.4 KB). Той звичайний рядок dynamic все ще витікає 90% рядків зі сторінок інших мов; кількість байтів низька, тому що тестовий додаток має малий обсяг вмісту. Адаптер 0% залишається постійним у міру зростання вмісту.
    • Компоненти мають розмір у 7-9 разів менший. Компоненти use-intl мають середній розмір 76-87 KB у кожній стратегії, оскільки useTranslations привʹязаний до цілого об'єкту message провайдера. Адаптер має середній розмір 9-11 KB.
    • Переключення локалі працює швидше. Оптимізовані налаштування use-intl потребують 13-21 мс для оновлення html[lang]; адаптер потребує 4-9 мс. Менше компонентів перерендерюється, і ніщо не переважається із дерева повідомлень.
    • static зберігає кожну локаль. Рядок адаптера static показує витік локалі 49.7%, той же, що й у нативному Intlayer у режимі static: усі локалі bundled, але лише словники сторінки. Один рядок конфігурації (importMode: 'dynamic') видаляє його.

    Чому змінюються цифри

    Ніщо в компоненті не змінилось, тому виграш повністю походить від того, до чого привʹязаний useTranslations.

    З next-intl, binding це провайдер. NextIntlClientProvider отримує весь об'єкт messages для локалі; кожен useTranslations("about") читає з нього. Bundler бачить один компонент, що імпортує один hook, який читає один контекст, і не може знати, що використовується тільки гілка about. Маршрути нижче всі ділять один і той же об'єкт messages, тому колонка page-leak читає ~90%, доки ви самі не розділите файл.

    bash
    .
    ├── messages
       ├── en.json                       # кожен namespace, кожна сторінка
       └── 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")
    

    З @intlayer/next-intl, привʼязка - це словник. syncJSON перетворює messages/en.json на один словник для кожного ключа верхнього рівня; компілятор визначає, який компонент викликає useTranslations("about"), і передає йому about безпосередньо, активною мовою, як імпорт, який bundler може розпізнати та розділити.

    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() тепер повертає proxy Intlayer
        └── app/[locale]
            ├── layout.tsx                # <NextIntlClientProvider> (без props messages)
            └── about/page.tsx            # useTranslations("about")  ← без змін
    

    src/i18n.ts та props messages зникають. Все інше залишається ідентичним.

    Міграція в три кроки

    1. Встановлення

      bash
      npx intlayer init --interactive
      

      Команда виявляє next-intl і встановлює intlayer, next-intlayer, @intlayer/next-intl та @intlayer/sync-json-plugin. Тримайте next-intl встановленим: це peer dependency адаптера і надає типи.

    2. Вкажіть Intlayer на ваші повідомлення

      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" об'єднує кожну локаль; "dynamic" завантажує активну на вимогу
          importMode: "dynamic",
        },
        plugins: [
          syncJSON({
            // ICU placeholders: {name}, {count, plural, one {# item} other {# items}}
            format: "icu",
            source: ({ locale }) => `./messages/${locale}.json`,
            location: "messages",
          }),
        ],
      };
      
      export default config;
      

      messages/{locale}.json залишається на місці. Кожен ключ верхнього рівня стає словником; useTranslations("about") відображається на словник about.

    3. Обгорнути 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() компонує withIntlayer (спостереження за контентом, компіляція словників, оптимізаційний прохід) та next-intl@intlayer/next-intl aliases для Webpack та Turbopack. Збуйте, і цифри в таблицях вище будуть вашими.

    Що ви можете видалити потім

    Файл / шаблонЧому
    getRequestConfig в src/i18n.tsНемає завантаження повідомлень за запитом. Збережіть файл лише якщо він також експортує createNavigation помічники
    messages={...} на NextIntlClientProviderАдаптер читає скомпільовані результати; властивість ігнорується та логує попередження під час розробки
    await getMessages() у layoutsЗ тієї ж причини
    Per-page pick(messages, [...])Компілятор виконує вибір для кожного компонента

    Що ви отримуєте крім зменшення розміру

    • Типізовані ключі. useTranslations("about") типізована проти скомпільованого словника about. t("does.not.exist") - це помилка TypeScript, а не fallback під час виконання.
    • npx intlayer test не проходить CI, коли локалі не вистачає ключа. npx intlayer fill перекладає відсутні за допомогою обраного провайдера (OpenAI, Anthropic, Mistral, Gemini...) використовуючи ваш власний ключ, і записує результат назад у messages/{locale}.json.
    • Visual Editor і CMS працюють з одними й тими ж словниками, тому розробники, що не є розробниками, можуть редагувати messages/fr.json через UI і файл оновлюється.
    • Постійний перехід до .content.ts. Будь-який компонент може перейти з useTranslations("about") на useIntlayer("about") з co-located файлом вмісту, один за одним. JSON і .content.ts словники співіснують і об'єднуються.

    Обмеження, які варто знати перед початком

    • Routing config переміщується до intlayer.config.ts. createNavigation(routing) та createMiddleware(routing) зберігають свою сигнатуру, але ігнорують аргумент: locales, default locale та стратегія префіксу беруться з Intlayer's routing config. Якщо ви використовуєте next-intl's локалізовані pathnames (/about/a-propos), адаптер їх не інтерполює; Intlayer's routing.rewrite охоплює цей випадок, але це окрема зміна.
    • useTranslations() без namespace не прив'язаний. Optimize pass потребує static namespace, щоб знати який dictionary імпортувати. Bare call все ще працює через runtime registry, який посилається на кожен dictionary, що є точно витоком, який ви намагалися видалити. Передавайте namespace.
    • Адаптер не безкоштовний. 8.0 КБ runtime проти 5.5 КБ для next-intlayer, та +6-7 КБ на сторінку над нативною збіркою. Це плата за next-intl API surface. Якщо ви дійшли до точки, де кожний компонент був перенесений на useIntlayer, відмовтеся від адаптера.
    • messages, timeZone, now на провайдері ігноруються. Форматери підтримуються нативним Intl і лише locale впливає на їх вихід; якщо ви покладаєтеся на примусову часову зону або фіксований now для hydration-стійких дат, обробіть це на місці виклику.

    Коли використовувати що?

    • Залишайтеся на next-intl якщо ваш додаток малий, ваш bundle не є проблемою, і ваша команда комфортно володіє namespaces та pick() на сторінку.
    • Використовуйте @intlayer/next-intl, якщо ви вже використовуєте next-intl і хочете отримати переваги у bundle, витоки та hydration, типізовані ключі та інструменти CLI / CMS без переписування. Це рекомендована точка входу для будь-якої існуючої codebase next-intl.
    • Перейдіть на native (next-intlayer) для нових проектів або після того, як адаптер виконав свою роботу. Це найлегший із трьох (5.5 KB, +0.3 KB на сторінку) і розблоковує синхронні server components, файли .content.ts за компонентом та повний набір функцій.

    Пов'язані порівняння

    Висновок

    @intlayer/next-intl робить одне: змінює те, до чого привязується useTranslations, від provider'а, який містить кожне повідомлення, до dictionary, скомпільованого для цього компонента. На тій же Next.js додатку, що коштує 6 KB на сторінку, компоненти в 2.7x менші, 0% витоку та 2 ms гідратації, перед тим, як хтось відкриє файл компонента. Navigation і middleware зберігають свій API поверх конфігу маршрутизації Intlayer, а нативний next-intlayer runtime залишається ще легшим.

    Усі вихідні дані, тестові додатки та скрипти знаходяться в репозиторії Benchmark Bloom. Запустіть це самостійно.

    Зверніться до документації 'Why Intlayer?' для більш детальної інформації.

    Коментарі

    Поки що немає коментарів. Будьте першим, хто поділиться своїми думками.

    Схожі публікації

    Останні публікації