Использование библиотеки FormatJS на сервере позволяет централизованно решать задачи локализации: форматирование дат, чисел, валют, относительного времени, обработка переводов и управление сообщениями без привязки к браузеру. В среде Node.js библиотека особенно полезна для:
Серверная интернационализация отличается от клиентской тем, что язык определяется до генерации ответа, а форматирование выполняется на стороне 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
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'
]));
Некоторые окружения 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}}"
}
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')
);
}
Основной объект для серверной локализации:
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);
FormatJS использует ICU MessageFormat.
{
"welcome": "Добро пожаловать, {name}"
}
intl.formatMessage(
{id: 'welcome'},
{name: 'Ирина'}
);
{
"notifications": "{count, plural,
=0 {Нет уведомлений}
one {# уведомление}
few {# уведомления}
many {# уведомлений}
other {# уведомлений}
}"
}
{
"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
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);
});
Accept-Language: ru-RU,ru;q=0.9,en;q=0.8
const locale = req.query.lang || 'en';
const locale = req.cookies.locale;
const locale = payload.locale;
Обработка отсутствующих переводов:
const intl = createIntl({
locale: 'ru',
defaultLocale: 'en',
messages
}, cache);
Если перевод отсутствует в ru, будет использован
английский вариант.
const intl = createIntl({
locale: 'ru',
messages,
onError(error) {
console.error(error);
}
}, cache);
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 — дорогая операция.
Правильный подход:
const cache = createIntlCache();
Использование общего cache существенно снижает нагрузку на сервер.
Для больших проектов:
const messages = await import(
`../locales/${locale}.json`,
{
assert: {
type: 'json'
}
}
);
Разделение переводов по модулям:
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
Преимущества:
Автоматический поиск переводимых строк:
formatjs extract "src/**/*.{js,jsx,ts,tsx}"
Исходный код:
intl.formatMessage({
defaultMessage: 'Добро пожаловать'
});
Результат extraction:
{
"abc123": {
"defaultMessage": "Добро пожаловать"
}
}
Использование только 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'
});
const subject = intl.formatMessage({
id: 'email.welcome.subject'
});
const body = intl.formatMessage(
{
id: 'email.welcome.body'
},
{
name: user.name
}
);
res.json({
message: req.intl.formatMessage({
id: 'user.created'
})
});
throw new Error(
intl.formatMessage({
id: 'errors.accessDenied'
})
);
Типизация сообщений:
type Messages = typeof import('../locales/ru.json');
declare global {
interface IntlMessages extends Messages {}
}
CLI-команда:
formatjs compile-folder \
--ast \
locales \
compiled
Основные рекомендации:
createIntlCache()
formatjs compile-folder
Плохо:
app.get('/', () => {
const intl = createIntl(...);
});
Хорошо:
const intlMap = new Map();
const localesCache = new Map();
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.addHook('preHandler', async (req) => {
req.intl = getIntl(
req.headers['accept-language'] || 'en'
);
});
const resolvers = {
Query: {
profile(_, args, context) {
return {
title: context.intl.formatMessage({
id: 'profile.title'
})
};
}
}
};
FormatJS совместим с:
Особенности:
Проблемы:
Решение:
Сложные ICU-конструкции трудно поддерживать:
"{count, plural, one {...} few {...} many {...}}"
Для крупных проектов часто создаются internal helper-утилиты.
expect(
intl.formatMessage({
id: 'app.title'
})
).toMatchSnapshot();
expect(
intl.formatMessage(
{id: 'items'},
{count: 5}
)
).toBe('5 товаров');
Нельзя напрямую вставлять пользовательский HTML в переводы:
{
"danger": "<script>alert(1)</script>"
}
Безопасный подход:
intl.formatMessage(...)
без dangerouslySetInnerHTML.
Типичная архитектура:
packages/
├── i18n-core/
├── i18n-locales/
├── i18n-cli/
└── shared-translations/
Преимущества:
Подходит для:
Популярные варианты:
Неправильно:
return `${price} USD`;
Правильно:
intl.formatNumber(price, {
style: 'currency',
currency: 'USD'
});
Логи обычно не локализуются.
Плохо:
logger.error(
intl.formatMessage(...)
);
Хорошо:
logger.error('ACCESS_DENIED');
Локализуется только пользовательский вывод.
Наиболее стабильная поддержка FormatJS наблюдается в:
Современные версии содержат улучшенную поддержку ICU и Intl API.