Next.js и SSR

Интернационализация в приложениях на базе Next.js требует синхронизации сразу нескольких уровней:

  • серверного рендеринга;
  • маршрутизации;
  • загрузки переводов;
  • гидратации React;
  • переключения локалей;
  • кеширования;
  • SEO.

Библиотека FormatJS предоставляет низкоуровневую и высокоуровневую инфраструктуру для локализации JavaScript-приложений. В React-экосистеме основным пакетом выступает react-intl.

В контексте Next.js чаще всего используются:

npm install react-intl intl-messageformat

Основные возможности:

  • ICU Message Syntax;
  • plural/sel ect правила;
  • форматирование дат и чисел;
  • SSR-совместимость;
  • динамическая загрузка переводов;
  • извлечение сообщений;
  • строгая типизация сообщений.

SSR и проблемы локализации

При серверном рендеринге возникает несколько критически важных задач:

  1. Сервер должен определить локаль.
  2. Сервер обязан загрузить правильные переводы.
  3. HTML должен быть отрендерен уже локализованным.
  4. Клиент при гидратации обязан получить идентичные данные.
  5. Нельзя допускать рассинхронизацию локалей между сервером и браузером.

Если локаль на сервере и клиенте отличается, 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:

  • уменьшается bundle size;
  • переводы разделяются по чанкам;
  • сервер загружает только нужную локаль.

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

_app.tsx

import 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>
  );
}

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

import { loadMessages } from '@/i18n/loadMessages';

export async function getServerSideProps(context) {
  const locale = context.locale || 'en';

  const messages = await loadMessages(locale);

  return {
    props: {
      locale,
      messages,
    },
  };
}

Теперь HTML будет локализован уже на сервере.


Локализованные компоненты

FormattedMessage

import { 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"
/>

Результат зависит от локали:

  • ru15 января 2026 г.
  • enJanuary 15, 2026

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

import { FormattedNumber } from 'react-intl';

<FormattedNumber
  value={1500000}
  style="currency"
  currency="USD"
/>

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

import { FormattedRelativeTime } from 'react-intl';

<FormattedRelativeTime
  value={-1}
  unit="day"
/>

Результат:

вчера

или:

yesterday

ICU Message Syntax

Главное преимущество FormatJS — поддержка ICU.


Pluralization

{
  "cart.items": "{count, plural, =0 {Корзина пуста} one {# товар} few {# товара} many {# товаров} other {# товара}}"
}

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

intl.formatMessage(
  { id: 'cart.items' },
  { count: 5 }
);

Select

{
  "user.gender": "{gender, select, male {Он} female {Она} other {Они}} онлайн"
}

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

{
  "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>
    </>
  );
}

SSR и 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';
}

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

В Next.js Middleware удобно реализовывать редиректы локалей.

middleware.ts

import { 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
  );
}

App Router и FormatJS

Начиная с Next.js 13+, App Router стал стандартом.


Серверные компоненты

В App Router серверные компоненты рендерятся на сервере по умолчанию.

Это означает:

  • переводы можно загружать напрямую;
  • SSR становится проще;
  • меньше клиентского JavaScript.

Пример структуры

app/
├── [locale]/
│   ├── layout.tsx
│   ├── page.tsx
│   └── about/

layout.tsx

import { 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' },
  ];
}

Server Components и ограничения

react-intl изначально создавался для клиентских компонентов.

Некоторые API:

  • используют React Context;
  • требуют "use client".

Из-за этого часто создают отдельный клиентский провайдер.


Client Provider

IntlClientProvider.tsx

'use client';

import { IntlProvider } from 'react-intl';

export function IntlClientProvider({
  locale,
  messages,
  children,
}) {
  return (
    <IntlProvider
      locale={locale}
      messages={messages}
    >
      {children}
    </IntlProvider>
  );
}

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

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>
  );
}

Динамическая загрузка переводов

В больших приложениях тысячи сообщений.

Нежелательно загружать все переводы одновременно.


Namespace-подход

messages/
├── common/
├── dashboard/
├── profile/
└── admin/

Загрузка namespace

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 может создавать нагрузку.


In-memory cache

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

При использовании Edge Runtime необходимо учитывать ограничения:

  • отсутствует Node.js API;
  • некоторые polyfill недоступны;
  • часть Intl API зависит от окружения.

Polyfills

Для старых браузеров могут понадобиться:

npm install @formatjs/intl-pluralrules
npm install @formatjs/intl-relativetimeformat

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

import '@formatjs/intl-pluralrules/polyfill';
import '@formatjs/intl-relativetimeformat/polyfill';

Оптимизация bundle size

FormatJS предоставляет Babel-плагины.


Babel Plugin

npm install babel-plugin-formatjs

Конфигурация

{
  "plugins": [
    [
      "formatjs",
      {
        "idInterpolationPattern":
          "[sha512:contenthash:base64:6]"
      }
    ]
  ]
}

Автоматическая генерация ID

Без плагина:

intl.formatMessage({
  defaultMessage: 'Привет'
});

С плагином автоматически создаётся стабильный ID.


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

FormatJS умеет автоматически собирать все сообщения проекта.


CLI

npm install @formatjs/cli

Извлечение

formatjs extract "src/**/*.{ts,tsx}" \
  --out-file lang/en.json

Компиляция переводов

formatjs compile lang/en.json \
  --out-file compiled/en.json

Предварительная компиляция ICU

Компиляция ускоряет runtime.

Вместо постоянного парсинга ICU:

"{count, plural, one {# item} other {# items}}"

создаются оптимизированные структуры.


SEO и локализация

SSR особенно важен для SEO.

Поисковые системы должны видеть:

  • локализованный HTML;
  • правильный <html lang="">;
  • hreflang;
  • локализованные title и description.

Генерация metadata

App Router

export async function generateMetadata({
  params,
}) {
  const messages =
    await loadMessages(params.locale);

  return {
    title: messages['home.title'],
  };
}

hreflang

<link
  rel="alternate"
  hrefLang="ru"
  href="https://site.com/ru"
/>

<link
  rel="alternate"
  hrefLang="en"
  href="https://site.com/en"
/>

Ошибки гидратации

Наиболее частые причины:

  • разная локаль;
  • разное время;
  • разные timezone;
  • разный набор сообщений;
  • случайные значения;
  • разные defaultLocale.

Проблема timezone

Сервер может использовать UTC, а клиент — локальную timezone.

<FormattedDate
  value={Date.now()}
/>

На сервере:

15 January

На клиенте:

16 January

Решение

Явно задавать timezone:

<IntlProvider
  locale="ru"
  timeZone="Europe/Moscow"
>

Missing messages

FormatJS умеет предупреждать о пропущенных переводах.

<IntlProvider
  locale="ru"
  messages={messages}
  onEr ror={(err) => {
    console.error(err);
  }}
>

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

intl.formatMessage({
  id: 'unknown.key',
  defaultMessage: 'Fallback'
});

TypeScript и типизация сообщений

Можно автоматически типизировать ID переводов.


Генерация типов

import messages from './messages/en.json';

type MessageKeys = keyof typeof messages;

Типизированный helper

export function t(
  intl,
  id: MessageKeys
) {
  return intl.formatMessage({ id });
}

Lazy hydration

При SSR часть локализованных компонентов можно гидратировать позже.

Это особенно полезно для:

  • виджетов;
  • чатов;
  • аналитики;
  • второстепенных UI-блоков.

Dynamic import

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

Возможность Pages Router App Router
SSR getServerSideProps встроенный
SSG getStaticProps generateStaticParams
Server Components нет да
Потоковый рендеринг ограничен встроен
Layout API ограниченный полноценный
Работа с locale проще гибче
React Suspense ограничен полноценный

Потоковый SSR и локализация

В React 18 используется streaming SSR.

FormatJS совместим с потоковым рендерингом, если:

  • переводы загружены заранее;
  • locale стабилен;
  • provider инициализирован до stream render.

React Server Components и будущее FormatJS

Современная архитектура Next.js постепенно смещает локализацию на сервер:

  • меньше клиентского JS;
  • меньше hydration cost;
  • быстрее initial render;
  • лучше SEO.

Однако React Context всё ещё делает часть API FormatJS клиентскими.

Поэтому распространён гибридный подход:

  • загрузка сообщений на сервере;
  • IntlProvider в client component;
  • перевод серверных данных до передачи в клиент;
  • ICU только в интерактивных элементах.