У меня появилась задача в проекте:

  • Перевести личный кабинет пользователя на русский и английский (в перспективе и на другие языки).

  • При этом, определять язык пользователя при первом заходе в ЛК и давать его изменить.

  • Запоминать выбранный язык при перезагрузке страницы.

  • Сделать так, чтобы в проектах была типизация файлов с переводами (чтобы нельзя было забыть добавить один из языков).

Как я это делал — расскажу в статье.

Содержание

Первоначальное решение

Сначала я решил задачу, просто закинув все переводы в несколько .ts-файлов с общим интерфейсом и выбирая язык через Redux. Всё работало, но было ощущение, что это я переизобрел велосипед.

Хотелось чего-то более стандартного и популярного на рынке: по-любому эту задачу кто-то уже решил более качественно. Да и всё-таки онбординг новых разработчиков никто не отменял. Поэтому было принято решение: выбрать популярную библиотеку и перенести переводы на неё.

Выбор библиотеки переводов

Для решения задачи я выбрал i18next.

Почему именно i18next?

  • Имеет поддержку типизации "из коробки" (дружит TS типы и даже кое-как с автокомплишном).

  • "Дружит" с React Server Component в Next.js (для Next.js 13+).

  • Поддерживает lazy loading (разделение переводов по чанкам/файлам) для ускорения страниц.

  • Всё выше делается просто относительной других популярных библиотек.

Глобально, сейчас из этого всего в проекте нужна только типизация. Но закладываю серверные компоненты и разделение кода на будущее. Проект планирует расширять, и эти возможности пригодятся для SEO.

Для наглядности сделал таблицу, где сравнил три самые популярные библиотеки:

i18next

react-intl

@lingui/react

Популярность
(звёзд на GitHub)

? 7.9k

?14.4k

?4.8k

Типизация

?

?(нужно повозиться)

?

Server Side Components

?

?(нужно повозиться)

?

Lazy loading

? (через namespaces)

?(нужно повозиться с динамическим импортом)

?(нужно повозиться)

Субъективное удобство

? (вызов всего через t('...'))

?(все переводы нужно оборачивать в компонент)

?(все переводы нужно оборачивать в компонент и нет нормальной типизации)

*оценка может быть субъективной из расчёта на конкретный проект, на объективность не претендую.

*i18n - расшифровывается как "internationalization".

Минутка самопиара

У меня есть Telegram-канал, где я собираю ссылки на свои статьи про Full-Stack разработку, развитие SaaS-продуктов и управление IT-проектами.

Создаём шаблон проекта

Для примера будем делать одну страницу с переключателем языка и несколькими текстовыми полями:

Страница для примера
Страница для примера

Для начала создадим новый проект на Next.js с TypeScript шаблоном.

Выполняем команды:

npx create-next-app@latest my-multilang-app --typescript

Я сразу добавил ESLint, TailwindCSS и Turbopack:

Появляется структура:

my-multilang-app/
├─ app/
│   ├─ page.tsx 
│   └─ ...
├─ public/
├─ ...
└─ package.json

И сразу добавляем библиотеки i18next:

npm install i18next react-i18next i18next-browser-languagedetector

react-i18next — адаптер для React.
i18next-browser-languagedetector — плагин для определения языка в браузере.

Добавляем локализацию

Создаём переводы

Создаём тип переводов и сами переводы в папке i18n:

i18n/
├─ translations/en_translation.json
├─ translations/ru_translation.json
├─ translations/TranslationTypes.ts
└─ i18n.ts

Разумеется, можно назвать файлы и папки по-другому, главное, чтобы была понятная структура. Я выбрал нейминг, стандартный для Feature Sliced Design (но FSD мы здесь не используем).

Далее сами файлы:

i18n/translations/TranslationTypes.ts

export interface TranslationTypes {
  // Используем схему componentName.field
  page: {
    hello: string;
    changeLanguage: string;
    dashboardTitle: string;
    profile: string;
  };
}

i18n/translations/en_translation.json

{
  "page": {
    "hello": "Hello, {{name}}!",
    "changeLanguage": "Change language to Russian",
    "dashboardTitle": "User Dashboard",
    "profile": "My profile"
  }
}

i18n/translations/ru_translation.json

{
  "page": {
    "hello": "Привет, {{name}}!",
    "changeLanguage": "Переключить язык на английский",
    "dashboardTitle": "Личный кабинет",
    "profile": "Мой профиль"
  }
}

Добавляем типы переводов в проект

Чтобы включить типизацию, нужно воспользоваться встроенным механизмом декларации типов i18next. Создадим файл resources.d.ts (или i18n.d.ts) в корне проекта или в папке types, где пропишем:

import "i18next";
import { TranslationTypes } from "@/i18n/translations/TranslationTypes";

declare module "i18next" {
  interface CustomTypeOptions {
    resources: TranslationTypes;
  }
}

Теперь при использовании useTranslation и t в нашем коде TypeScript будет подсказывать, какие ключи перевода у нас существуют.

Инициализируем i18next

Добавим файл i18n.ts в папку /i18n:

import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";

import { TranslationTypes } from "./translations/TranslationTypes";
import en from "./translations/en_translation.json";
import ru from "./translations/ru_translation.json";

// Если забудем добавить поле в один из языков,
// здесь появится TypeScript ошибка
const resources: Record<string, { translation: TranslationTypes }> = {
  en: { translation: en },
  ru: { translation: ru },
};

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources,
    detection: {
      order: ["localStorage", "navigator"],
      caches: ["localStorage"],
      lookupLocalStorage: "i18nextLng",
    },
    fallbackLng: "en",
    interpolation: {
      escapeValue: false,
    },
  });

export default i18n;

Обратите внимание, что i18next-browser-languagedetector смотрит, какой язык установлен в браузере, а также может работать с cookie/localStorage. Это решает задачу "запоминать язык при перезагрузке страницы".

Здесь мы указываем логику, в которой сначала пытаемся брать язык из localStorage, а затем из браузера:

order: ["localStorage", "navigator"],

i18next умеет сам выбирать нужный язык в зависимости от настройки браузера (ru, en, sp и другие). Нам нужно только указать нужный файл для языка:

const resources: Record<string, { translation: TranslationTypes }> = {
  en: { translation: en },
  ru: { translation: ru },
};

...
...
    // Если нужного языка нет, берём английский
    fallbackLng: "en",
    ...
...

Добавляем выбор языка

Чтобы пользователь мог переключать язык, создадим компонент выбора языка:

LanguageSwitcher.tsx:

"use client";

import { useTranslation } from "react-i18next";

export default function LanguageSwitcher() {
  const { i18n } = useTranslation();

  const changeLanguage = async (lang: "en" | "ru") => {
    await i18n.changeLanguage(lang);
  };

  return (
    <div className="mt-4 space-x-2">
      <button
        onClick={() => changeLanguage("en")}
        className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
      >
        EN
      </button>

      <button
        onClick={() => changeLanguage("ru")}
        className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
      >
        RU
      </button>
    </div>
  );
}

Теперь мы можем переиспользовать этот компонент на любой странице или в header'e.

Проброс языка в API

Если у вас локализация распространяется и на API, вам нужно прокидывать язык в запросы. Его можно брать из нашего файла i18n и добавлять в заголовок. Например, в fetch-запросе:

await fetch('/api/user', {
  headers: {
    'Authorization': getAccessToken(),
    'Accept-Language': i18n.language, // берем текущий язык
  }
});

Итоговая страница

Полный пример страницы с переводами выглядит вот так:

app/page.tsx

"use client";

import LanguageSwitcher from "@/components/LanguageSwitcher";
import { useTranslation } from "react-i18next";
import "../i18n/i18n";

export default function HomePage() {
  const { t } = useTranslation();

  return (
    <main className="p-8 max-w-4xl mx-auto">
      <h1 className="text-3xl font-bold text-gray-900 mb-6">
        {t("page.dashboardTitle")}
      </h1>

      <div className="space-y-4">
        <p className="text-lg text-gray-700">
          {t("page.hello", { name: "John" })}
        </p>
        <p className="text-lg text-gray-700">{t("page.profile")}</p>
      </div>

      {/* Кнопка для переключения языка */}
      <div className="mt-8">
        <LanguageSwitcher />
      </div>
    </main>
  );
}

Как результат смены языка в LanguageSwitcher будут меняться все надписи, а при перезагрузке страницы сохранится последний выбранный язык:

Итоговая страница
Итоговая страница

Конкретно в этом примере мы используем "use client" для упрощения. В следующей статье я покажу, как использовать i18next с SSR'ом.

Заключение

Итого, при смене языка у нас меняются все тексты на странице:

  • Заголовок «Личный кабинет» <> «User Dashboard»

  • Приветствие «Привет, John!» <> «Hello, John!»

  • Кнопка для профиля «Мой профиль» <> «My profile»

А при перезагрузке приложения язык остаётся выбранным, так как i18next-browser-languagedetector сохраняет язык в localStorage'e.

Чтобы добавить новые языки (испанский, китайский и т.д.) нужно расширить ресурс в i18n.ts и добавить новые файлы с переводами (например, es_translation.json, zh_translation.json). Типизация подскажет, не забыли ли мы какие-то поля.

P.S. Напомню, что у меня есть Telegram-канал, где я собираю ссылки на свои статьи про Full-Stack разработку, развитие SaaS-продуктов и управление IT-проектами.

Если остались вопросы, пишите в комментариях!

Комментарии (10)


  1. pqbd
    16.01.2025 10:18

    Зачем там типизация, если я всёравно где-нибудь, да напишу t("page.helo"

    Мне бы больше хотелось что-то типа t.page.hello / t.page.hello({name: 'world'})

    Кстати, переводы это не только два килограмма перевод меток, но и направление текста, так ещё и вёрстку под конкртеный язык может понадобиться менять


    1. RostislavDugin Автор
      16.01.2025 10:18

      Типизацию через точки я тоже хочу. Точнее даже накостылил её. Но ни одна относительно популярная библиотека её не даёт.

      Поэтому промежуточный вариант - это типизация самих переводов (чтобы не потерять ни одно из полей). А если вы напишите t("page.helo") - у вас всё равно ни на одном языке не отобразиться перевод и вы это заметите.

      Про направление текста и т.д. - в текущем проекте мы на это забили. Используем только языки, которые пишутся слева направо.

      Единственная разница в вёрстке, что текст на русском или чуть длиннее, или +1 строка. Но т.к. размерности никто не хардкодит, всё спокойно адаптируется


      1. gsaw
        16.01.2025 10:18

        Есть библиотека, next-international, в ней все ключи типизированны. Не опечатаешься. К тому же с ней проще с серверными компонентами работать, n18next я не осилил нормально с SSR увязать.


    1. Dartess
      16.01.2025 10:18

      t(page.hello) довольно легко можно накостылить, сгенерировав структуру с соответствующими значениями по каждому ключу. Плюсом я для себя подшаманил вывод типов, чтобы по hover-у вместо бесполезного string (который по факту находится по каждому ключу) писало текст перевода, правда, это возможно только если переводы лежат не в json-е, из него пока ts не может вывести строгий тип. У нас по историческим причинам оригиналы текстов лежат в ts-файле.


  1. shsv382
    16.01.2025 10:18

    Спасибо, что делитесь информацией!

    До сих пор не понимаю, почему такие статьи минусят


    1. RostislavDugin Автор
      16.01.2025 10:18

      Спасибо :)

      Я недавно смотрел подкаст с @Boomburum (один из руководителей хабра). Он говорит, что это норма, если узкоспециализированные статьи в первые часы собирают минусы. Потом рейтинг выравнивается

      Это из-за того, что на хабре много токсичных товарищей, который минусуют вообще всё подряд без учета тематики. А потом подтягивается 5%-10% аудитории, которой статья в тему — и возвращают рейтинг в плюс.


    1. Grikus
      16.01.2025 10:18

      Потому что нагородил всякого хлама.

      Задаёшь дефолтный язык, ру например, чтобы не плодить кучу файлов, деофлтнвй текст можно задать так же через , в самом блоке текста {t{kod, "text}}, далее парсим в файлик тексты со всего проекта, к примеру сканером или парсером. Появляется исходный файлов со всеми текстами со всего проекта. Дальше осталось только конфигурации настроить самих переводов, например через гул апи. Он проходит по исходном файлику ru.json, переводит на заданные языки и создаёт сам en.json и тд, какие там. Все осталось уважать смену языков с файлами переводов

      А тут какая то шняга кривая ещё и вручную


      1. RostislavDugin Автор
        16.01.2025 10:18

        Итого, вы предложили:

        1. Взять нестандартный подход, без документации, покрытия тестами и незнакомый для новых программистов.

        2. Зачем-то потратить время на написание парсера (и его тестирование).

        3. Перевести ~2500 строк текста (в моём случае) через API, который не будет учитывать контекст фраз, местоимения и терминологию проекта.

        4. Про проблемы, которые появятся с ростом переводов до 20к строк+, разделением кода и серверным рендерингом я молчу.

        Резюмируя, "создаём велосипед руками и головняк на будущее". Если проект на 1-2 человека и это привычно - тогда ок. Но для проекта больше - это лишнее.

        В статье как раз описан подход, где берётся стандартное протестированное решение, знакомое программистам на рынке. Без велосипедостроения и отлавливания багов за счёт бюджета проекта.


        1. Grikus
          16.01.2025 10:18

          1. Вполне стандартный подход, если ты его не видел не значит что он нестандартный

          2. Партнёры давно уже впитывает в реакт , читайте пункт 1

          3. Ну ка расскажи мне способ идеального автоматизированной перевода со всеми эти фичами, чтобы не страдал контекст? 20к строк? В моем примере указан автоматизированный перевод, уж какой есть в апи, хочешь ручками - пожалуйста, твоё право, суть подхода не меняется, вместо созданного через апи файл втыкаю свой, какие проблемы

          4. Причём тут разделение кода, и северный рендеринг?)))) Речь про создание системы для переводов

            Дружище,ты читай разные источники, а не делай вывод и суждение по первому попавшимуся, который сразу у тебя в голове становиться эталонным


  1. radist2s
    16.01.2025 10:18

    Не выбирайте i18-next для новых проектов, пожалуйста, это самое отсталое решение.