Интернационализация в приложениях на базе Next.js требует синхронизации сразу нескольких уровней:
Библиотека FormatJS
предоставляет низкоуровневую и высокоуровневую инфраструктуру для
локализации JavaScript-приложений. В React-экосистеме основным пакетом
выступает react-intl.
В контексте Next.js чаще всего используются:
npm install react-intl intl-messageformat
Основные возможности:
При серверном рендеринге возникает несколько критически важных задач:
Если локаль на сервере и клиенте отличается, React выдаст ошибки гидратации:
Text content does not match server-rendered HTML
FormatJS хорошо подходит для SSR, поскольку IntlProvider
работает одинаково и на сервере, и на клиенте.
Типичная структура:
src/
├── i18n/
│ ├── messages/
│ │ ├── en.json
│ │ ├── ru.json
│ │ └── de.json
│ ├── config.ts
│ └── loadMessages.ts
├── pages/
├── components/
└── app/
Файл config.ts:
export const locales = ['en', 'ru', 'de'];
export const defaultLocale = 'en';
ru.json:
{
"home.title": "Главная страница",
"home.description": "Пример локализации Next.js",
"menu.about": "О проекте"
}
en.json:
{
"home.title": "Home page",
"home.description": "Next.js localization example",
"menu.about": "About"
}
Файл loadMessages.ts:
export async function loadMessages(locale: string) {
switch (locale) {
case 'ru':
return (await import('./messages/ru.json')).default;
case 'de':
return (await import('./messages/de.json')).default;
default:
return (await import('./messages/en.json')).default;
}
}
Динамический импорт особенно важен для SSR:
_app.tsximport type { AppProps } fr om 'next/app';
import { IntlProvider } from 'react-intl';
export default function App({
Component,
pageProps,
}: AppProps) {
return (
<IntlProvider
locale={pageProps.locale}
messages={pageProps.messages}
>
<Component {...pageProps} />
</IntlProvider>
);
}
getServerSidePropsimport { loadMessages } from '@/i18n/loadMessages';
export async function getServerSideProps(context) {
const locale = context.locale || 'en';
const messages = await loadMessages(locale);
return {
props: {
locale,
messages,
},
};
}
Теперь HTML будет локализован уже на сервере.
FormattedMessageimport { FormattedMessage } from 'react-intl';
export default function HomePage() {
return (
<h1>
<FormattedMessage id="home.title" />
</h1>
);
}
useIntlХук предоставляет полный доступ к API форматирования.
import { useIntl } from 'react-intl';
export function Header() {
const intl = useIntl();
return (
<h1>
{intl.formatMessage({
id: 'home.title',
})}
</h1>
);
}
import { FormattedDate } from 'react-intl';
<FormattedDate
value={new Date()}
year="numeric"
month="long"
day="2-digit"
/>
Результат зависит от локали:
ru → 15 января 2026 г.en → January 15, 2026import { FormattedNumber } from 'react-intl';
<FormattedNumber
value={1500000}
style="currency"
currency="USD"
/>
import { FormattedRelativeTime } from 'react-intl';
<FormattedRelativeTime
value={-1}
unit="day"
/>
Результат:
вчера
или:
yesterday
Главное преимущество FormatJS — поддержка ICU.
{
"cart.items": "{count, plural, =0 {Корзина пуста} one {# товар} few {# товара} many {# товаров} other {# товара}}"
}
Использование:
intl.formatMessage(
{ id: 'cart.items' },
{ count: 5 }
);
{
"user.gender": "{gender, select, male {Он} female {Она} other {Они}} онлайн"
}
{
"notifications": "{count, plural, one {{gender, select, male {Он} female {Она} other {Они}} отправил уведомление} other {{gender, select, male {Он} female {Она} other {Они}} отправили уведомления}}"
}
FormatJS корректно обрабатывает сложные комбинации.
Next.js поддерживает встроенный i18n routing.
next.config.js:
module.exports = {
i18n: {
locales: ['en', 'ru', 'de'],
defaultLocale: 'en',
},
};
Маршруты автоматически становятся:
/en/about
/ru/about
/de/about
import { useRouter } fr om 'next/router';
export function LocaleSwitcher() {
const router = useRouter();
const changeLocale = (locale: string) => {
router.push(
router.pathname,
router.asPath,
{ locale }
);
};
return (
<>
<button onCl ick={() => changeLocale('en')}>
EN
</button>
<button onCl ick={() => changeLocale('ru')}>
RU
</button>
</>
);
}
Accept-LanguageЛокаль можно определять автоматически.
export async function getServerSideProps(context) {
const language =
context.req.headers['accept-language'];
console.log(language);
return {
props: {},
};
}
Пример заголовка:
ru-RU,ru;q=0.9,en-US;q=0.8
function detectLocale(header?: string) {
if (!header) {
return 'en';
}
if (header.includes('ru')) {
return 'ru';
}
if (header.includes('de')) {
return 'de';
}
return 'en';
}
В Next.js Middleware удобно реализовывать редиректы локалей.
middleware.tsimport { NextResponse } from 'next/server';
export function middleware(request) {
const pathname = request.nextUrl.pathname;
const hasLocale =
pathname.startsWith('/en') ||
pathname.startsWith('/ru') ||
pathname.startsWith('/de');
if (hasLocale) {
return NextResponse.next();
}
const locale = 'ru';
request.nextUrl.pathname =
`/${locale}${pathname}`;
return NextResponse.redirect(
request.nextUrl
);
}
Начиная с Next.js 13+, App Router стал стандартом.
В App Router серверные компоненты рендерятся на сервере по умолчанию.
Это означает:
app/
├── [locale]/
│ ├── layout.tsx
│ ├── page.tsx
│ └── about/
layout.tsximport { IntlProvider } from 'react-intl';
import { loadMessages } from '@/i18n/loadMessages';
export default async function RootLayout({
children,
params,
}) {
const messages =
await loadMessages(params.locale);
return (
<html lang={params.locale}>
<body>
<IntlProvider
locale={params.locale}
messages={messages}
>
{children}
</IntlProvider>
</body>
</html>
);
}
export async function generateStaticParams() {
return [
{ locale: 'en' },
{ locale: 'ru' },
{ locale: 'de' },
];
}
react-intl изначально создавался для клиентских
компонентов.
Некоторые API:
"use client".Из-за этого часто создают отдельный клиентский провайдер.
IntlClientProvider.tsx'use client';
import { IntlProvider } from 'react-intl';
export function IntlClientProvider({
locale,
messages,
children,
}) {
return (
<IntlProvider
locale={locale}
messages={messages}
>
{children}
</IntlProvider>
);
}
import { IntlClientProvider }
from '@/components/IntlClientProvider';
export default async function Layout({
children,
params,
}) {
const messages =
await loadMessages(params.locale);
return (
<html lang={params.locale}>
<body>
<IntlClientProvider
locale={params.locale}
messages={messages}
>
{children}
</IntlClientProvider>
</body>
</html>
);
}
В больших приложениях тысячи сообщений.
Нежелательно загружать все переводы одновременно.
messages/
├── common/
├── dashboard/
├── profile/
└── admin/
export async function loadMessages(
locale: string,
namespace: string
) {
return (
await import(
`./messages/${namespace}/${locale}.json`
)
).default;
}
const common =
await loadMessages(locale, 'common');
const dashboard =
await loadMessages(locale, 'dashboard');
const messages = {
...common,
...dashboard,
};
При SSR постоянный импорт JSON может создавать нагрузку.
const cache = new Map();
export async function loadMessages(locale) {
if (cache.has(locale)) {
return cache.get(locale);
}
const messages =
(await import(`./messages/${locale}.json`))
.default;
cache.set(locale, messages);
return messages;
}
При использовании Edge Runtime необходимо учитывать ограничения:
Для старых браузеров могут понадобиться:
npm install @formatjs/intl-pluralrules
npm install @formatjs/intl-relativetimeformat
import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-relativetimeformat/polyfill';
FormatJS предоставляет Babel-плагины.
npm install babel-plugin-formatjs
{
"plugins": [
[
"formatjs",
{
"idInterpolationPattern":
"[sha512:contenthash:base64:6]"
}
]
]
}
Без плагина:
intl.formatMessage({
defaultMessage: 'Привет'
});
С плагином автоматически создаётся стабильный ID.
FormatJS умеет автоматически собирать все сообщения проекта.
npm install @formatjs/cli
formatjs extract "src/**/*.{ts,tsx}" \
--out-file lang/en.json
formatjs compile lang/en.json \
--out-file compiled/en.json
Компиляция ускоряет runtime.
Вместо постоянного парсинга ICU:
"{count, plural, one {# item} other {# items}}"
создаются оптимизированные структуры.
SSR особенно важен для SEO.
Поисковые системы должны видеть:
<html lang="">;export async function generateMetadata({
params,
}) {
const messages =
await loadMessages(params.locale);
return {
title: messages['home.title'],
};
}
<link
rel="alternate"
hrefLang="ru"
href="https://site.com/ru"
/>
<link
rel="alternate"
hrefLang="en"
href="https://site.com/en"
/>
Наиболее частые причины:
defaultLocale.Сервер может использовать UTC, а клиент — локальную timezone.
<FormattedDate
value={Date.now()}
/>
На сервере:
15 January
На клиенте:
16 January
Явно задавать timezone:
<IntlProvider
locale="ru"
timeZone="Europe/Moscow"
>
FormatJS умеет предупреждать о пропущенных переводах.
<IntlProvider
locale="ru"
messages={messages}
onEr ror={(err) => {
console.error(err);
}}
>
intl.formatMessage({
id: 'unknown.key',
defaultMessage: 'Fallback'
});
Можно автоматически типизировать ID переводов.
import messages from './messages/en.json';
type MessageKeys = keyof typeof messages;
export function t(
intl,
id: MessageKeys
) {
return intl.formatMessage({ id });
}
При SSR часть локализованных компонентов можно гидратировать позже.
Это особенно полезно для:
const ChatWidget = dynamic(
() => import('./ChatWidget'),
{
ssr: false,
}
);
Практически всегда используется:
| Задача | Решение |
|---|---|
| SSR | IntlProvider |
| Роутинг | Next.js i18n |
| ICU | FormatJS |
| Загрузка | dynamic import |
| SEO | SSR metadata |
| Производительность | precompile |
| Типизация | TypeScript |
| Кеширование | in-memory cache |
| Namespace | feature-based |
| Возможность | Pages Router | App Router |
|---|---|---|
| SSR | getServerSideProps |
встроенный |
| SSG | getStaticProps |
generateStaticParams |
| Server Components | нет | да |
| Потоковый рендеринг | ограничен | встроен |
| Layout API | ограниченный | полноценный |
| Работа с locale | проще | гибче |
| React Suspense | ограничен | полноценный |
В React 18 используется streaming SSR.
FormatJS совместим с потоковым рендерингом, если:
Современная архитектура Next.js постепенно смещает локализацию на сервер:
Однако React Context всё ещё делает часть API FormatJS клиентскими.
Поэтому распространён гибридный подход: