Как загружать переводы из файлов в React Router v7

Отделяйте переводимый контент от кода

Проблема

Жёстко прописывая строки для пользователей прямо в компонентах, вы сильно связываете контент с кодом. Для добавления нового языка разработчикам приходится менять файлы реализации, усложнять логику и увеличивать сложность. Даже небольшие правки текста требуют деплоя кода. Такой подход делает процесс перевода зависимым от работы инженеров и не даёт нетехническим членам команды управлять контентом самостоятельно.

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

Решение

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

Такой подход позволяет переводчикам работать напрямую с JSON-файлами без доступа к коду, обновлять контент простым изменением файлов и иметь единый источник правды для всех строк на каждом языке.

Шаги

1. Создайте файлы переводов для каждой локали

Соберите файлы переводов в отдельной папке, по одному JSON-файлу на язык. В каждом файле используйте плоскую структуру: ключ — идентификатор сообщения, значение — перевод.

{
"welcome.title": "Welcome back",
"welcome.subtitle": "Continue where you left off",
"nav.home": "Home",
"nav.about": "About",
"nav.contact": "Contact"
}

Сохраните это как app/translations/en.json для английского, затем создайте аналогичные файлы вроде app/translations/es.json и app/translations/fr.json с переводами для других языков. Используйте одинаковые ключи во всех файлах, чтобы один и тот же идентификатор всегда соответствовал нужному переводу в каждой локали.

2. Загрузка переводов в загрузчике маршрута

Используйте загрузчик маршрута, чтобы получить файл перевода для текущей локали до рендера. Так сообщения будут доступны при монтировании компонентов.

import type { Route } from "./+types/root";
import enMessages from "./translations/en.json";
import esMessages from "./translations/es.json";
import frMessages from "./translations/fr.json";

const messages: Record<string, Record<string, string>> = {
  en: enMessages,
  es: esMessages,
  fr: frMessages,
};

export async function loader({ request }: Route.LoaderArgs) {
  const url = new URL(request.url);
  const locale = url.searchParams.get("locale") || "en";

  return {
    locale,
    messages: messages[locale] || messages.en,
  };
}

Загрузчик читает локаль из параметра запроса в URL и возвращает как локаль, так и соответствующие ей сообщения. Компоненты могут получить эти данные через loaderData, чтобы настроить провайдер интернационализации.

3. Настройка IntlProvider с загруженными сообщениями

Оборачивайте приложение в IntlProvider из react-intl, передавая локаль и сообщения из данных загрузчика.

import { IntlProvider } from "react-intl";
import { Outlet } from "react-router";
import type { Route } from "./+types/root";

export default function Root({ loaderData }: Route.ComponentProps) {
  return (
    <IntlProvider locale={loaderData.locale} messages={loaderData.messages}>
      <html lang={loaderData.locale}>
        <head>
          <meta charSet="utf-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1" />
        </head>
        <body>
          <Outlet />
        </body>
      </html>
    </IntlProvider>
  );
}

IntlProvider делает локаль и сообщения доступными для всех дочерних компонентов через React context. Дочерние маршруты рендерятся через Outlet и наследуют доступ к данным переводов.

4. Использование идентификаторов сообщений в компонентах

Заменяйте жёстко заданные строки на компоненты FormattedMessage, которые ссылаются на идентификаторы сообщений из ваших файлов переводов.

import { FormattedMessage } from "react-intl";

export default function Welcome() {
  return (
    <div>
      <h1>
        <FormattedMessage id="welcome.title" />
      </h1>
      <p>
        <FormattedMessage id="welcome.subtitle" />
      </p>
      <nav>
        <a href="/">
          <FormattedMessage id="nav.home" />
        </a>
        <a href="/about">
          <FormattedMessage id="nav.about" />
        </a>
        <a href="/contact">
          <FormattedMessage id="nav.contact" />
        </a>
      </nav>
    </div>
  );
}

Каждый компонент FormattedMessage ищет свой id в объекте messages, который передаёт IntlProvider, и отображает соответствующую переведённую строку. Когда локаль меняется и загрузчик снова получает другие сообщения, все компоненты автоматически показывают новые переводы без изменений в коде.