Node.js на сервере

Использование библиотеки FormatJS на сервере позволяет централизованно решать задачи локализации: форматирование дат, чисел, валют, относительного времени, обработка переводов и управление сообщениями без привязки к браузеру. В среде Node.js библиотека особенно полезна для:

  • SSR-приложений;
  • REST API;
  • GraphQL-сервисов;
  • серверного рендеринга React;
  • генерации HTML и PDF;
  • email-шаблонов;
  • микросервисов с мультиязычной поддержкой.

Серверная интернационализация отличается от клиентской тем, что язык определяется до генерации ответа, а форматирование выполняется на стороне Node.js.


Установка зависимостей

Базовый набор пакетов:

npm install react-intl intl-messageformat @formatjs/intl

Для работы исключительно на сервере без React:

npm install intl-messageformat

Дополнительные пакеты:

npm install @formatjs/intl-relativetimeformat
npm install @formatjs/intl-numberformat
npm install @formatjs/intl-datetimeformat

Intl API в Node.js

FormatJS строится поверх встроенного API Intl.

Основные компоненты:

API Назначение
Intl.DateTimeFormat Форматирование дат
Intl.NumberFormat Форматирование чисел
Intl.RelativeTimeFormat Относительное время
Intl.PluralRules Правила множественного числа
Intl.ListFormat Форматирование списков

Node.js использует ICU (International Components for Unicode). Полная поддержка локалей зависит от сборки Node.js.

Проверка доступных локалей:

console.log(Intl.DateTimeFormat.supportedLocalesOf([
  'en',
  'fr',
  'ru',
  'de'
]));

Подключение polyfill в Node.js

Некоторые окружения Node.js не содержат полный ICU-пакет.

Подключение polyfill:

import '@formatjs/intl-relativetimeformat/polyfill';
import '@formatjs/intl-relativetimeformat/locale-data/ru';

import '@formatjs/intl-numberformat/polyfill';
import '@formatjs/intl-numberformat/locale-data/ru';

Структура переводов

Типичная организация каталогов:

src/
 ├── locales/
 │    ├── en.json
 │    ├── ru.json
 │    └── de.json
 ├── i18n/
 │    ├── formatter.js
 │    └── messages.js
 └── server.js

Пример ru.json:

{
  "app.title": "Панель управления",
  "user.greeting": "Здравствуйте, {name}",
  "cart.items": "{count, plural, one {# товар} few {# товара} many {# товаров} other {# товаров}}"
}

Пример en.json:

{
  "app.title": "Dashboard",
  "user.greeting": "Hello, {name}",
  "cart.items": "{count, plural, one {# item} other {# items}}"
}

Загрузка переводов

Динамическая загрузка locale-файлов

import fs from 'fs';
import path from 'path';

export function loadMessages(locale) {
  const filePath = path.join(
    process.cwd(),
    'src/locales',
    `${locale}.json`
  );

  return JSON.parse(
    fs.readFileSync(filePath, 'utf8')
  );
}

Создание formatter

Основной объект для серверной локализации:

import {createIntl, createIntlCache} from 'react-intl';

const cache = createIntlCache();

export function createFormatter(locale, messages) {
  return createIntl({
    locale,
    messages
  }, cache);
}

Форматирование сообщений

Простое сообщение

const intl = createFormatter('ru', messages);

const result = intl.formatMessage({
  id: 'app.title'
});

console.log(result);

Интерполяция параметров

const greeting = intl.formatMessage(
  {
    id: 'user.greeting'
  },
  {
    name: 'Алексей'
  }
);

console.log(greeting);

ICU Message Syntax

FormatJS использует ICU MessageFormat.

Переменные

{
  "welcome": "Добро пожаловать, {name}"
}
intl.formatMessage(
  {id: 'welcome'},
  {name: 'Ирина'}
);

Множественное число

{
  "notifications": "{count, plural,
    =0 {Нет уведомлений}
    one {# уведомление}
    few {# уведомления}
    many {# уведомлений}
    other {# уведомлений}
  }"
}

Select

{
  "gender.message": "{gender, select,
    male {Он вошёл}
    female {Она вошла}
    other {Они вошли}
  }"
}

Вложенные конструкции

{
  "complex": "{count, plural,
    one {{gender, select,
      male {Он добавил}
      female {Она добавила}
      other {Они добавили}
    } # комментарий}
    other {{gender, select,
      male {Он добавил}
      female {Она добавила}
      other {Они добавили}
    } # комментариев}
  }"
}

Форматирование дат

const result = intl.formatDate(new Date(), {
  year: 'numeric',
  month: 'long',
  day: 'numeric'
});

console.log(result);

Для русской локали:

29 мая 2026 г.

Форматирование времени

const time = intl.formatTime(new Date(), {
  hour: 'numeric',
  minute: 'numeric'
});

Форматирование диапазонов дат

const range = intl.formatDateTimeRange(
  new Date('2026-05-01'),
  new Date('2026-05-10')
);

Форматирование чисел

const number = intl.formatNumber(1234567.89);

Результат для ru:

1 234 567,89

Форматирование валют

const price = intl.formatNumber(1999.99, {
  style: 'currency',
  currency: 'KZT'
});

Результат:

1 999,99 ₸

Компактная запись чисел

intl.formatNumber(1500000, {
  notation: 'compact'
});

Результат:

1,5 млн

Относительное время

const value = intl.formatRelativeTime(-5, 'day');

Результат:

5 дней назад

Форматирование списков

const formatter = new Intl.ListFormat('ru', {
  style: 'long',
  type: 'conjunction'
});

console.log(
  formatter.format(['Node.js', 'React', 'Vue'])
);

Результат:

Node.js, React и Vue

Форматирование в Express

Middleware локализации

import express fr om 'express';
import {createIntl, createIntlCache} from 'react-intl';
import {loadMessages} from './messages.js';

const app = express();

const cache = createIntlCache();

app.use((req, res, next) => {
  const locale =
    req.headers['accept-language']?.split(',')[0] || 'en';

  const messages = loadMessages(locale);

  req.intl = createIntl({
    locale,
    messages
  }, cache);

  next();
});

Использование в маршрутах

app.get('/', (req, res) => {
  const title = req.intl.formatMessage({
    id: 'app.title'
  });

  res.send(title);
});

Определение локали

Через HTTP-заголовок

Accept-Language: ru-RU,ru;q=0.9,en;q=0.8

Через query-параметр

const locale = req.query.lang || 'en';

const locale = req.cookies.locale;

Через JWT

const locale = payload.locale;

Fallback-локализация

Обработка отсутствующих переводов:

const intl = createIntl({
  locale: 'ru',
  defaultLocale: 'en',
  messages
}, cache);

Если перевод отсутствует в ru, будет использован английский вариант.


Обработка отсутствующих сообщений

const intl = createIntl({
  locale: 'ru',
  messages,
  onError(error) {
    console.error(error);
  }
}, cache);

Серверный рендеринг React

Интеграция с React SSR

import {renderToString} from 'react-dom/server';
import {RawIntlProvider} from 'react-intl';

app.get('/', (req, res) => {
  const html = renderToString(
    <RawIntlProvider value={req.intl}>
      <App />
    </RawIntlProvider>
  );

  res.send(html);
});

Кэширование formatter

Создание formatter — дорогая операция.

Правильный подход:

const cache = createIntlCache();

Использование общего cache существенно снижает нагрузку на сервер.


Lazy Loading переводов

Для больших проектов:

const messages = await import(
  `../locales/${locale}.json`,
  {
    assert: {
      type: 'json'
    }
  }
);

Namespace-переводы

Разделение переводов по модулям:

locales/
 ├── ru/
 │    ├── auth.json
 │    ├── dashboard.json
 │    └── profile.json

Объединение:

const messages = {
  ...auth,
  ...dashboard,
  ...profile
};

Компиляция сообщений

FormatJS поддерживает предварительную компиляцию ICU-сообщений.

Установка CLI:

npm install --save-dev @formatjs/cli

Компиляция:

formatjs compile-folder locales compiled-locales

Преимущества:

  • ускорение runtime;
  • снижение нагрузки CPU;
  • уменьшение времени SSR.

Извлечение сообщений

Автоматический поиск переводимых строк:

formatjs extract "src/**/*.{js,jsx,ts,tsx}"

Пример extract

Исходный код:

intl.formatMessage({
  defaultMessage: 'Добро пожаловать'
});

Результат extraction:

{
  "abc123": {
    "defaultMessage": "Добро пожаловать"
  }
}

Форматирование без React

Использование только intl-messageformat:

import IntlMessageFormat from 'intl-messageformat';

const message = new IntlMessageFormat(
  'Здравствуйте, {name}',
  'ru'
);

console.log(
  message.format({
    name: 'Мария'
  })
);

Работа с часовыми поясами

intl.formatDate(new Date(), {
  timeZone: 'Asia/Almaty',
  hour: 'numeric',
  minute: 'numeric'
});

Форматирование для email

const subject = intl.formatMessage({
  id: 'email.welcome.subject'
});

const body = intl.formatMessage(
  {
    id: 'email.welcome.body'
  },
  {
    name: user.name
  }
);

Локализация API-ответов

res.json({
  message: req.intl.formatMessage({
    id: 'user.created'
  })
});

Локализация ошибок

throw new Error(
  intl.formatMessage({
    id: 'errors.accessDenied'
  })
);

Поддержка TypeScript

Типизация сообщений:

type Messages = typeof import('../locales/ru.json');

declare global {
  interface IntlMessages extends Messages {}
}

Проверка отсутствующих переводов

CLI-команда:

formatjs compile-folder \
  --ast \
  locales \
  compiled

Производительность

Основные рекомендации:

Использование cache

createIntlCache()

Предкомпиляция ICU

formatjs compile-folder

Минимизация formatter creation

Плохо:

app.get('/', () => {
  const intl = createIntl(...);
});

Хорошо:

const intlMap = new Map();

Кэш локалей

const localesCache = new Map();

Пример полноценного i18n-сервиса

import {createIntl, createIntlCache} from 'react-intl';
import fs from 'fs';

const cache = createIntlCache();

const loadedMessages = new Map();

function getMessages(locale) {
  if (loadedMessages.has(locale)) {
    return loadedMessages.get(locale);
  }

  const messages = JSON.parse(
    fs.readFileSync(
      `./locales/${locale}.json`,
      'utf8'
    )
  );

  loadedMessages.set(locale, messages);

  return messages;
}

export function getIntl(locale) {
  return createIntl({
    locale,
    messages: getMessages(locale),
    defaultLocale: 'en'
  }, cache);
}

Интеграция с Fastify

fastify.addHook('preHandler', async (req) => {
  req.intl = getIntl(
    req.headers['accept-language'] || 'en'
  );
});

Локализация GraphQL

const resolvers = {
  Query: {
    profile(_, args, context) {
      return {
        title: context.intl.formatMessage({
          id: 'profile.title'
        })
      };
    }
  }
};

Edge Runtime и serverless

FormatJS совместим с:

  • AWS Lambda
  • Vercel
  • Cloudflare Workers
  • Deno

Особенности:

  • минимизация bundle size;
  • lazy loading locale-data;
  • предварительная компиляция ICU.

Ограничения серверной локализации

Большие locale-файлы

Проблемы:

  • рост памяти;
  • увеличение startup-time;
  • медленный cold start.

Решение:

  • namespace;
  • lazy loading;
  • CDN;
  • кэширование.

ICU-сложность

Сложные ICU-конструкции трудно поддерживать:

"{count, plural, one {...} few {...} many {...}}"

Для крупных проектов часто создаются internal helper-утилиты.


Тестирование локализации

Snapshot-тесты

expect(
  intl.formatMessage({
    id: 'app.title'
  })
).toMatchSnapshot();

Проверка plural rules

expect(
  intl.formatMessage(
    {id: 'items'},
    {count: 5}
  )
).toBe('5 товаров');

Безопасность

Нельзя напрямую вставлять пользовательский HTML в переводы:

{
  "danger": "<script>alert(1)</script>"
}

Безопасный подход:

intl.formatMessage(...)

без dangerouslySetInnerHTML.


Организация enterprise-проектов

Типичная архитектура:

packages/
 ├── i18n-core/
 ├── i18n-locales/
 ├── i18n-cli/
 └── shared-translations/

Подходы к хранению переводов

JSON

Преимущества:

  • простота;
  • совместимость;
  • удобство extraction.

База данных

Подходит для:

  • CMS;
  • live-editing;
  • админ-панелей.

Headless CMS

Популярные варианты:

  • Strapi
  • Contentful
  • Sanity

Форматирование на уровне доменной логики

Неправильно:

return `${price} USD`;

Правильно:

intl.formatNumber(price, {
  style: 'currency',
  currency: 'USD'
});

Локализация логирования

Логи обычно не локализуются.

Плохо:

logger.error(
  intl.formatMessage(...)
);

Хорошо:

logger.error('ACCESS_DENIED');

Локализуется только пользовательский вывод.


Совместимость Node.js

Наиболее стабильная поддержка FormatJS наблюдается в:

  • Node.js 18+
  • Node.js 20+
  • Node.js 22+

Современные версии содержат улучшенную поддержку ICU и Intl API.