Автор:
    Создание: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. Никакие импорты в вашей codebase не переименовываются.
    2. JSON as source of truth. The syncJSON plugin читает ваши существующие messages/{locale}.json, разделяет его top-level ключи на один dictionary per namespace и записывает переводы обратно в те же файлы, когда CLI или CMS их обновляют. Рабочий процесс ваших переводчиков остается неизменным.
    3. Привязка на месте вызова. Оптимизирующий проход Intlayer (Babel или SWC) переписывает useTranslations("about") в вызов, который получает словарь about напрямую. Компонент больше не обращается к глобальному дереву сообщений; он обращается к своему собственному контенту.
    app/[locale]/about/page.tsx
    // Ваш код, без изменений
    import { useTranslations } from "next-intl";
    
    const AboutPage = () => {
      const t = useTranslations("about");
      return <h1>{t("title")}</h1>;
    };
    
    Что выдаёт компилятор (упрощённо)
    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 множественные числа, select, selectordinal, #, {ts, date, long} выполняются через Intlayer's ICU resolver
    useLocale() / getLocale() / setRequestLocale() / setLocale✅ Сохранено
    useFormatter()✅ Сохранено. dateTime, number, relativeTime, list, dateTimeRange подключаются к нативному Intl
    NextIntlClientProvider✅ Сохранено. Props messages, timeZone и now принимаются, но игнорируются (dev warning уведомит вас об этом)
    getMessages()✅ Сохранено для совместимости; больше не требуется
    getRequestConfig() в src/i18n.ts⚠️ Не требуется. Словари компилируются во время сборки; загрузки сообщений для каждого запроса нет
    defineRouting()✅ Сохранено. Опущенные поля (locales, defaultLocale, localePrefix) читаются из intlayer.config.ts
    createNavigation(), Link, redirect, usePathname, useRouter✅ Сохранено. Переимплементировано на основе конфигурации маршрутизации Intlayer; аргумент routing принимается, но игнорируется
    pathnames (локализованные имена маршрутов)❌ Принимается для типизации, не интерполируется. Сохраняйте простые имена маршрутов или переместите это сопоставление в rewrite Intlayer
    createMiddleware()✅ Сохранено. Возвращает прокси Intlayer; устанавливает куку NEXT_LOCALE так, чтобы useLocale() и ваш переключатель продолжали работать
    NEXT_LOCALE кука✅ Читается по умолчанию (если вы не настроили routing.storage самостоятельно)
    Bare useTranslations() без namespace⚠️ Работает, но сайт вызова не привязан: разрешается через runtime registry. Передайте namespace для получения выигрыша в bundle

    The benchmark

    What was measured

    Набор тестов 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) до оптимального (один namespace на маршрут + per-page pick()). Адаптер был построен на тех же компонентах, что и наивный подход, с измененными только next.config.ts и intlayer.config.ts. У него нет варианта "scoped": компилятор определяет содержимое на уровне компонента, поэтому его строки static и dynamic уже являются scoped.

    Для каждой сборки suite записывает:

    • 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.
    Приведённые ниже числа получены из прогона от 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 меньше на каждую страницу. Сборка адаптера наивного приложения приземляется на 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 КБ с next-intl (он достигает провайдера и дерева сообщений) и 8,1 КБ с адаптером. В конфигурации scoped-static next-intl это число увеличивается до 80 КБ, потому что файл пространства имён каждого маршрута становится доступным со страницы, которая его выбирает.
    • Гидратация на 2 мс быстрее (12.8 против 14.7 мс): нет необходимости десериализовать объект сообщения из RSC payload перед тем, как React может выполнить гидратацию.
    • Адаптер не является нативным runtime. next-intlayer занимает 141.3 KB, +0.3 KB относительно базового приложения, с runtime 5.5 KB. Адаптер предоставляет 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)Средний JS страницы (gz)Утечка локалиУтечка страницыСредний компонент (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-9x меньше. Компоненты use-intl в среднем занимают 76-87 KB при любой стратегии, потому что useTranslations привязан к полному объекту сообщений провайдера. Адаптер в среднем занимает 9-11 KB.
    • Переключение локалей работает быстрее. Оптимизированные настройки use-intl занимают 13-21 ms для обновления html[lang]; адаптер занимает 4-9 ms. Меньше компонентов перерисовывается, и ничего не переходит из дерева сообщений.
    • static сохраняет каждую локаль. Строка static адаптера показывает утечку локалей на 49.7%, как и native Intlayer в режиме static: все локали объединены, но используются только словари страницы. Одна строка конфига (importMode: 'dynamic') это убирает.

    Почему цифры меняются

    Ничего в компоненте не изменилось, поэтому прирост производительности полностью зависит от того, к чему привязана useTranslations.

    With next-intl, the binding is the provider. NextIntlClientProvider receives the whole messages object for the locale; every useTranslations("about") reads from it. The bundler sees one component importing one hook that reads one context, and cannot know that only the about branch is used. The routes below all share the same message object, so the page-leak column reads ~90% until you split the file yourself.

    bash
    .
    ├── messages
       ├── en.json                       # все пространства имён, все страницы
       └── 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                       # неизменно, остается источником истины
       └── fr.json
    ├── .intlayer/                        # сгенерировано: один словарь на каждый namespace, на каждый locale
    └── src
        ├── middleware.ts                 # createMiddleware() теперь возвращает прокси Intlayer
        └── app/[locale]
            ├── layout.tsx                # <NextIntlClientProvider> (без свойства messages)
            └── about/page.tsx            # useTranslations("about")  ← без изменений
    

    src/i18n.ts и свойство 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 заполнители: {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 (отслеживание контента, компиляция словарей, оптимизирующий проход) и aliases next-intl@intlayer/next-intl для Webpack и Turbopack. Постройте проект, и числа в таблицах выше будут вашими.

    Что вы можете удалить впоследствии

    Файл / шаблонПричина
    getRequestConfig в src/i18n.tsНет загрузки сообщений на каждый запрос. Оставьте файл только если он также экспортирует helpers createNavigation
    messages={...} на NextIntlClientProviderАдаптер читает скомпилированный output; свойство игнорируется и выводит предупреждение в разработке
    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 content файлом, по одному за раз. JSON и .content.ts словари сосуществуют и объединяются.

    Ограничения, которые нужно знать перед началом

    • Routing config переходит в intlayer.config.ts. createNavigation(routing) и createMiddleware(routing) сохраняют свою сигнатуру, но игнорируют аргумент: локали, локаль по умолчанию и стратегия префикса поступают из конфига Intlayer's routing. Если вы используете локализованные pathnames от next-intl (/about/a-propos), адаптер их не интерполирует; intlayer.routing.rewrite покрывает этот случай, но это отдельное изменение.
    • useTranslations() без namespace не привязан. Этап оптимизации требует статический namespace, чтобы знать, какой dictionary импортировать. Прямой вызов все еще работает через registry во время выполнения, который ссылается на каждый dictionary, что точно утечка, которую вы пытались избежать. Передайте namespace.
    • Адаптер не бесплатен. 8.0 KB runtime против 5.5 KB для next-intlayer и +6-7 KB на страницу по сравнению с native build. Это цена за API surface next-intl. Если вы дошли до точки, где каждый компонент был перемещен на useIntlayer, отказывайтесь от адаптера.
    • messages, timeZone, now на провайдере игнорируются. Форматеры поддерживаются нативным Intl и только locale влияет на их output; если вы полагаетесь на forced time zone или fixed now для hydration-stable дат, обработайте это на call site.

    Когда использовать что?

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

    Связанные сравнения

    Заключение

    @intlayer/next-intl делает одно: изменяет, к чему привязан useTranslations, от провайдера, содержащего все сообщения, к словарю, скомпилированному для этого компонента. На том же Next.js приложении, которое стоит 6 KB на страницу, компоненты в 2,7 раза меньше, 0% утечек и 2 мс гидратации, прежде чем кто-либо откроет файл компонента. Навигация и middleware сохраняют свой API поверх конфига маршрутизации Intlayer, а нативный runtime next-intlayer остается еще более легким.

    Все исходные данные, тестовые приложения и скрипты находятся в репозитории Benchmark Bloom. Запустите это самостоятельно.

    Дополнительные сведения см. в документации 'Why Intlayer?'.

    Комментарии

    Пока нет комментариев. Будьте первым, кто поделится своими мыслями.

    Похожие сообщения

    Последние сообщения