Как загружать переводы из файлов в TanStack Start v1

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

Проблема

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

Решение

Вынесите все переводимые строки из кода приложения во внешние JSON-файлы — по одному на каждый язык. Для каждого сообщения задайте стабильный ключ и используйте эти ключи в компонентах вместо текста. Во время выполнения загружайте нужный файл перевода в зависимости от локали пользователя и передавайте эти сообщения в IntlProvider из react-intl. Так контент отделяется от кода: переводчики могут работать напрямую с JSON-файлами, для изменения текста не нужны правки в коде, а добавление нового языка — это просто новый файл без изменений в компонентах.

Шаги

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

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

{
"welcome": "Welcome back",
"greeting": "Hello, {name}",
"itemCount": "{count, plural, =0 {No items} one {One item} other {# items}}"
}

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

2. Загрузка переводов с помощью серверной функции

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

import { createServerFn } from "@tanstack/react-start";
import * as fs from "node:fs";

export const getMessages = createServerFn({ method: "GET" }).handler(
  async ({ request }) => {
    const url = new URL(request.url);
    const locale = url.searchParams.get("locale") || "en";
    const filePath = `app/translations/${locale}.json`;
    const content = await fs.promises.readFile(filePath, "utf-8");
    return JSON.parse(content);
  },
);

Эта функция читает JSON-файл для выбранной локали и возвращает разобранный объект сообщений. Она выполняется только на сервере, что обеспечивает безопасность доступа к файловой системе.

3. Создайте помощник для определения локали пользователя

Определите небольшой утилитарный метод, который извлекает локаль из запроса или использует значение по умолчанию, чтобы его можно было переиспользовать в разных маршрутах.

export function getLocaleFromRequest(request: Request): string {
  const url = new URL(request.url);
  const localeParam = url.searchParams.get("locale");
  if (localeParam) return localeParam;
  const acceptLanguage = request.headers.get("accept-language");
  if (acceptLanguage) {
    const match = acceptLanguage.split(",")[0].split("-")[0];
    return match || "en";
  }
  return "en";
}

Эта функция сначала проверяет параметры запроса, затем заголовок Accept-Language, и по умолчанию выбирает английский. Это единый источник истины для определения локали.

4. Загрузка сообщений в загрузчике маршрута

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

import { createFileRoute } from "@tanstack/react-router";
import { getMessages, getLocaleFromRequest } from "../lib/i18n";

export const Route = createFileRoute("/")({
  loader: async ({ context }) => {
    const locale = getLocaleFromRequest(context.request);
    const messages = await getMessages({ data: { locale } });
    return { locale, messages };
  },
  component: HomePage,
});

function HomePage() {
  const { locale, messages } = Route.useLoaderData();
  return (
    <div>
      <p>{messages.welcome}</p>
    </div>
  );
}

Загрузчик вызывает серверную функцию для получения сообщений, а компонент получает их через useLoaderData. Такой подход работает и для SSR, и для клиентской навигации.

5. Передача сообщений в react-intl

Обёрните дерево компонентов в IntlProvider, передав выбранную локаль и сообщения, чтобы все дочерние компоненты могли получить доступ к переводам.

import { IntlProvider } from "react-intl";

function HomePage() {
  const { locale, messages } = Route.useLoaderData();
  return (
    <IntlProvider locale={locale} messages={messages}>
      <AppContent />
    </IntlProvider>
  );
}

function AppContent() {
  return (
    <div>
      <FormattedMessage id="welcome" />
    </div>
  );
}

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

6. Ссылайтесь на сообщения по ключу в компонентах

Используйте компонент react-intl FormattedMessage или хук useIntl для отображения переведённых строк, ссылаясь на ключи, определённые в ваших JSON-файлах.

import { FormattedMessage, useIntl } from "react-intl";

function UserGreeting({ name }: { name: string }) {
  const intl = useIntl();
  const title = intl.formatMessage({ id: "greeting" }, { name });
  return (
    <div>
      <h1 title={title}>
        <FormattedMessage id="greeting" values={{ name }} />
      </h1>
      <p>
        <FormattedMessage id="itemCount" values={{ count: 5 }} />
      </p>
    </div>
  );
}

FormattedMessage выводит переведённую строку прямо в разметке, а useIntl().formatMessage возвращает строку для использования в атрибутах или JavaScript-логике. Оба принимают values для интерполяции и поддерживают синтаксис ICU для плюрализации и форматирования.