Перейти к содержимому

20. Интернационализация (i18n)

Представь, что ты открываешь сайт на китайском языке. Ты ничего не понимаешь, закрываешь его и уходишь. А теперь представь — тот же сайт, но он говорит с тобой на твоём языке, показывает дату в привычном формате, и даже цены в рублях. Магия? Нет — это интернационализация (i18n)! 🎩

i18n — сокращение от “internationalization” (18 букв между “i” и “n”). Это процесс адаптации приложения для разных языков и регионов.

Интернационализация = i18n
Локализация = l10n (10 букв между "l" и "n")
i18n — это инфраструктура (как сделать переводимым)
l10n — это контент (сами переводы для каждого языка)

Аналогия: Представь международный аэропорт. В нём есть указатели на нескольких языках, объявления переводятся, деньги принимают разные, время показывают по местному. Без этого — хаос! Твоё веб-приложение — тот же аэропорт, только виртуальный.

Реальные цифры:

  • 75% пользователей предпочитают сайты на родном языке
  • 60% никогда не покупают на сайтах не на своём языке
  • Добавление 10 языков может удвоить аудиторию
Проблема без i18n │ Решение с i18n
───────────────────────────────┼────────────────────────────────
Все строки захардкожены в JSX │ Ключи переводов + JSON файлы
Даты в американском формате │ Intl.DateTimeFormat по локали
Числа без разделителей │ Intl.NumberFormat по локали
URL одинаковый для всех │ /en/about, /ru/about, /de/about
Нет SEO для других языков │ hreflang теги, отдельные URL

next-intl — лучшая библиотека для i18n в Next.js App Router. Создана специально для Server Components и имеет отличную TypeScript поддержку.

Окно терминала
npm install next-intl

После установки нужно настроить несколько файлов. Не пугайся — это один раз, потом просто пишешь переводы! 😊


my-app/
├── messages/ ← Папка с переводами
│ ├── en.json ← Английский
│ ├── ru.json ← Русский
│ └── de.json ← Немецкий
├── app/
│ └── [locale]/ ← Динамический сегмент локали!
│ ├── layout.tsx
│ ├── page.tsx
│ └── about/
│ └── page.tsx
├── i18n.ts ← Конфигурация next-intl
├── middleware.ts ← Определение локали из URL
└── next.config.mjs

Ключевой момент: папка [locale] — это динамический сегмент Next.js! Когда пользователь заходит на /ru/about, Next.js знает что locale = 'ru'.


Сначала создаём JSON файлы с переводами:

messages/en.json
{
"HomePage": {
"title": "Welcome to our store!",
"subtitle": "The best products at the best prices",
"greeting": "Hello, {name}!",
"itemsInCart": "{count, plural, =0 {No items} =1 {One item} other {# items}} in cart"
},
"ProductCard": {
"addToCart": "Add to cart",
"inStock": "In stock",
"outOfStock": "Out of stock",
"reviews": "{count, plural, =1 {# review} other {# reviews}}"
},
"Navigation": {
"home": "Home",
"about": "About",
"shop": "Shop",
"contact": "Contact"
},
"Common": {
"loading": "Loading...",
"error": "Something went wrong",
"retry": "Try again"
}
}
messages/ru.json
{
"HomePage": {
"title": "Добро пожаловать в наш магазин!",
"subtitle": "Лучшие товары по лучшим ценам",
"greeting": "Привет, {name}!",
"itemsInCart": "{count, plural, =0 {Корзина пуста} =1 {# товар} few {# товара} many {# товаров} other {# товара}} в корзине"
},
"ProductCard": {
"addToCart": "В корзину",
"inStock": "В наличии",
"outOfStock": "Нет в наличии",
"reviews": "{count, plural, =1 {# отзыв} few {# отзыва} many {# отзывов} other {# отзыва}}"
},
"Navigation": {
"home": "Главная",
"about": "О нас",
"shop": "Магазин",
"contact": "Контакты"
},
"Common": {
"loading": "Загрузка...",
"error": "Что-то пошло не так",
"retry": "Попробовать снова"
}
}
messages/de.json
{
"HomePage": {
"title": "Willkommen in unserem Shop!",
"subtitle": "Die besten Produkte zu den besten Preisen",
"greeting": "Hallo, {name}!",
"itemsInCart": "{count, plural, =0 {Keine Artikel} =1 {Ein Artikel} other {# Artikel}} im Warenkorb"
},
"ProductCard": {
"addToCart": "In den Warenkorb",
"inStock": "Auf Lager",
"outOfStock": "Nicht verfügbar",
"reviews": "{count, plural, =1 {# Bewertung} other {# Bewertungen}}"
},
"Navigation": {
"home": "Startseite",
"about": "Über uns",
"shop": "Shop",
"contact": "Kontakt"
},
"Common": {
"loading": "Wird geladen...",
"error": "Etwas ist schiefgelaufen",
"retry": "Erneut versuchen"
}
}

💡 Совет: Обрати внимание на plural rules (правила множественного числа). В русском языке их 4 формы (один, два-четыре, пять+, дробные). В немецком — 2. В арабском — 6! next-intl использует стандарт ICU MessageFormat, который знает правила для всех языков.


// i18n.ts — центральная конфигурация
import { notFound } from 'next/navigation';
import { getRequestConfig } from 'next-intl/server';
// Список поддерживаемых локалей
export const locales = ['en', 'ru', 'de'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
// Эта функция запускается на сервере при каждом запросе
export default getRequestConfig(async ({ locale }) => {
// Проверяем что локаль поддерживается
if (!locales.includes(locale as Locale)) notFound();
return {
// Загружаем файл переводов для текущей локали
messages: (await import(`./messages/${locale}.json`)).default,
// Настройки форматирования по умолчанию
timeZone: 'Europe/Moscow',
now: new Date(),
};
});

Middleware — это “привратник” твоего приложения. Он встречает каждый запрос и решает: какую локаль использовать?

middleware.ts
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from './i18n';
export default createMiddleware({
// Все поддерживаемые локали
locales,
// Локаль по умолчанию (если не определена из URL)
defaultLocale,
// Стратегия определения локали
localeDetection: true, // Определять по Accept-Language заголовку
});
// Middleware применяется только к этим путям
export const config = {
matcher: [
// Все пути кроме статических файлов и API
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};

Как работает определение локали:

1. Пользователь заходит на "/"
2. Middleware смотрит Accept-Language заголовок браузера
3. Если браузер говорит "ru-RU" → редирект на "/ru"
4. Если язык не поддерживается → редирект на "/en" (default)
5. Если уже есть локаль в URL ("/de/shop") → пропускаем

app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { locales, type Locale } from '@/i18n';
interface Props {
children: React.ReactNode;
params: { locale: string };
}
export async function generateMetadata({ params: { locale } }: Props) {
const t = await getTranslations({ locale, namespace: 'HomePage' });
return {
title: t('title'),
};
}
export async function generateStaticParams() {
// Генерируем статические пути для всех локалей
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params: { locale } }: Props) {
// Проверяем валидность локали
if (!locales.includes(locale as Locale)) notFound();
// Получаем все сообщения для текущей локали (серверная сторона)
const messages = await getMessages();
// Определяем направление текста (LTR или RTL)
const dir = ['ar', 'he', 'fa', 'ur'].includes(locale) ? 'rtl' : 'ltr';
return (
<html lang={locale} dir={dir}>
<body>
{/* Провайдер передаёт переводы клиентским компонентам */}
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}

🎯 Важно: NextIntlClientProvider передаёт переводы от Server Component к Client Components. Без него useTranslations в клиентских компонентах не будет работать!


Server Components могут использовать await getTranslations() — это самый быстрый способ, переводы приходят прямо с сервера:

// app/[locale]/page.tsx — Server Component
import { getTranslations, getFormatter } from 'next-intl/server';
interface Props {
params: { locale: string };
}
export default async function HomePage({ params: { locale } }: Props) {
// getTranslations — async функция для Server Components
const t = await getTranslations('HomePage');
const format = await getFormatter();
const launchDate = new Date('2024-01-15');
const revenue = 1234567.89;
return (
<main>
<h1>{t('title')}</h1>
<p>{t('subtitle')}</p>
{/* Параметры в переводах */}
<p>{t('greeting', { name: 'Яша' })}</p>
{/* Форматирование даты */}
<p>
Дата запуска:{' '}
{format.dateTime(launchDate, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
{/* Форматирование числа/валюты */}
<p>
Выручка:{' '}
{format.number(revenue, {
style: 'currency',
currency: locale === 'ru' ? 'RUB' : locale === 'de' ? 'EUR' : 'USD',
})}
</p>
</main>
);
}

// components/ProductCard.tsx — Client Component
'use client';
import { useTranslations, useFormatter, useLocale } from 'next-intl';
import { useState } from 'react';
interface Product {
id: number;
name: string;
price: number;
stock: number;
reviewCount: number;
createdAt: Date;
}
interface Props {
product: Product;
}
export function ProductCard({ product }: Props) {
const t = useTranslations('ProductCard');
const format = useFormatter();
const locale = useLocale();
const [added, setAdded] = useState(false);
// Определяем валюту по локали
const currency =
locale === 'ru' ? 'RUB' : locale === 'de' ? 'EUR' : 'USD';
return (
<div className="product-card">
<h3>{product.name}</h3>
{/* Форматирование цены с валютой */}
<p className="price">
{format.number(product.price, {
style: 'currency',
currency,
maximumFractionDigits: 0,
})}
</p>
{/* Статус наличия */}
<span className={product.stock > 0 ? 'in-stock' : 'out-of-stock'}>
{product.stock > 0 ? t('inStock') : t('outOfStock')}
</span>
{/* Плюрализация отзывов */}
<p className="reviews">
{t('reviews', { count: product.reviewCount })}
</p>
{/* Дата добавления */}
<p className="date">
{format.dateTime(product.createdAt, {
dateStyle: 'medium',
})}
</p>
{/* Кнопка добавления */}
<button
onClick={() => setAdded(true)}
disabled={product.stock === 0 || added}
>
{added ? '✓' : t('addToCart')}
</button>
</div>
);
}

Плюрализация в разных языках — настоящий зверь! 🦁 Вот почему:

Язык │ Правила │ Пример
──────────┼────────────────────────────────────────────┼─────────────────
Русский │ 1, 2-4, 5-20, дробные │ 1 товар, 2 товара, 5 товаров
Английский│ 1, остальные │ 1 item, 2 items
Немецкий │ 1, остальные │ 1 Artikel, 2 Artikel
Арабский │ 0, 1, 2, 3-10, 11-99, остальные │ 6 правил!
Польский │ 1, 2-4 (кроме 12-14), 5+, дробные │ похоже на русский
Китайский │ одно правило (нет склонений) │ 1个, 2个, 100个
// Плюрализация в ICU MessageFormat
{
"items": "{count, plural, =0 {Нет товаров} =1 {# товар} few {# товара} many {# товаров} other {# товара}}",
"messages": "{count, plural, =0 {Нет сообщений} =1 {Одно сообщение} few {# сообщения} many {# сообщений} other {# сообщения}}",
// Выбор (не плюрализация, а ветвление)
"gender": "{gender, select, male {Он купил} female {Она купила} other {Они купили}}"
}
// Использование в коде
const t = useTranslations('Cart');
// next-intl автоматически выберет правильную форму!
t('items', { count: 0 }); // "Нет товаров"
t('items', { count: 1 }); // "1 товар"
t('items', { count: 3 }); // "3 товара"
t('items', { count: 11 }); // "11 товаров" (исключение!)
t('items', { count: 21 }); // "21 товар"

'use client';
import { useFormatter } from 'next-intl';
function DateExamples() {
const format = useFormatter();
const date = new Date('2024-03-15T14:30:00');
return (
<div>
{/* Только дата */}
<p>{format.dateTime(date, { dateStyle: 'full' })}</p>
// EN: "Friday, March 15, 2024"
// RU: "пятница, 15 марта 2024 г."
// DE: "Freitag, 15. März 2024"
{/* Дата и время */}
<p>{format.dateTime(date, { dateStyle: 'medium', timeStyle: 'short' })}</p>
// EN: "Mar 15, 2024, 2:30 PM"
// RU: "15 мар. 2024 г., 14:30"
// DE: "15.03.2024, 14:30"
{/* Относительное время (как в соцсетях!) */}
<p>{format.relativeTime(-3, 'hours')}</p>
// EN: "3 hours ago"
// RU: "3 часа назад"
// DE: "vor 3 Stunden"
{/* Диапазон дат */}
<p>
{format.dateTimeRange(
new Date('2024-06-01'),
new Date('2024-06-30'),
{ month: 'long', year: 'numeric' }
)}
</p>
// EN: "June 2024"
// RU: "июнь 2024 г."
</div>
);
}

'use client';
import { useFormatter, useLocale } from 'next-intl';
function NumberExamples() {
const format = useFormatter();
const locale = useLocale();
const currencies: Record<string, string> = {
en: 'USD',
ru: 'RUB',
de: 'EUR',
};
const currency = currencies[locale] ?? 'USD';
return (
<div>
{/* Простое число */}
<p>{format.number(1234567.89)}</p>
// EN: "1,234,567.89"
// RU: "1 234 567,89"
// DE: "1.234.567,89"
{/* Валюта */}
<p>{format.number(99.99, { style: 'currency', currency })}</p>
// EN: "$99.99"
// RU: "99,99 ₽"
// DE: "99,99 €"
{/* Проценты */}
<p>{format.number(0.856, { style: 'percent', maximumFractionDigits: 1 })}</p>
// EN: "85.6%"
// RU: "85,6 %"
// DE: "85,6 %"
{/* Компактный формат */}
<p>{format.number(1500000, { notation: 'compact' })}</p>
// EN: "1.5M"
// RU: "1,5 млн"
// DE: "1,5 Mio."
{/* Единицы измерения */}
<p>{format.number(25, { style: 'unit', unit: 'celsius' })}</p>
// EN: "25°C"
// RU: "25°C"
</div>
);
}

Некоторые языки читаются справа налево (Right-to-Left). Это требует зеркального отражения всего интерфейса!

// app/[locale]/layout.tsx — уже показали выше, повторим ключевой момент
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi'];
const dir = rtlLocales.includes(locale) ? 'rtl' : 'ltr';
// В HTML:
<html lang={locale} dir={dir}> // ← вот этот атрибут!
/* CSS автоматически зеркалится при dir="rtl" */
/* Используй логические свойства вместо физических! */
/* ❌ Физические (не работают с RTL) */
.button {
margin-left: 8px;
padding-right: 16px;
border-left: 2px solid blue;
text-align: left;
}
/* ✅ Логические (автоматически зеркалятся!) */
.button {
margin-inline-start: 8px; /* left в LTR, right в RTL */
padding-inline-end: 16px; /* right в LTR, left в RTL */
border-inline-start: 2px solid blue;
text-align: start; /* left в LTR, right в RTL */
}
// Иконки тоже нужно зеркалить!
'use client';
import { useLocale } from 'next-intl';
function Arrow() {
const locale = useLocale();
const isRTL = ['ar', 'he'].includes(locale);
return (
<svg
style={{ transform: isRTL ? 'scaleX(-1)' : 'none' }}
viewBox="0 0 24 24"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
);
}

components/LanguageSwitcher.tsx
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, usePathname } from 'next/navigation';
import { locales, type Locale } from '@/i18n';
const localeNames: Record<Locale, string> = {
en: '🇬🇧 English',
ru: '🇷🇺 Русский',
de: '🇩🇪 Deutsch',
};
export function LanguageSwitcher() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const switchLocale = (newLocale: Locale) => {
// Убираем текущую локаль из пути
// "/ru/about" → "/about"
const pathWithoutLocale = pathname.replace(`/${locale}`, '') || '/';
// Добавляем новую локаль
// "/about" → "/de/about"
router.push(`/${newLocale}${pathWithoutLocale}`);
};
return (
<div className="language-switcher">
{locales.map((loc) => (
<button
key={loc}
onClick={() => switchLocale(loc)}
className={loc === locale ? 'active' : ''}
aria-current={loc === locale ? 'true' : undefined}
>
{localeNames[loc]}
</button>
))}
</div>
);
}
// Альтернатива — через Link (лучше для SEO!)
import Link from 'next/link';
import { useLocale } from 'next-intl';
import { usePathname } from 'next/navigation';
export function LanguageSwitcherLinks() {
const locale = useLocale();
const pathname = usePathname();
const getLocalizedPath = (newLocale: string) => {
const pathWithoutLocale = pathname.replace(`/${locale}`, '') || '/';
return `/${newLocale}${pathWithoutLocale}`;
};
return (
<nav aria-label="Language switcher">
<Link href={getLocalizedPath('en')} hrefLang="en">EN</Link>
<Link href={getLocalizedPath('ru')} hrefLang="ru">RU</Link>
<Link href={getLocalizedPath('de')} hrefLang="de">DE</Link>
</nav>
);
}

Это killer feature next-intl! Ты получаешь автодополнение для ключей переводов:

types/next-intl.d.ts
// Шаг 1: Создай файл types/next-intl.d.ts
import en from '../messages/en.json';
type Messages = typeof en;
declare global {
// Use type safe message keys with `next-intl`
interface IntlMessages extends Messages {}
}

Теперь TypeScript знает все ключи переводов:

// ✅ Автодополнение и проверка типов!
const t = useTranslations('HomePage');
t('title'); // ✅ OK
t('subtitle'); // ✅ OK
t('nonExistent'); // ❌ TypeScript ошибка!
t('greeting'); // ❌ TypeScript ошибка — нужен параметр {name}!
t('greeting', { name: 'Яша' }); // ✅ OK
// Типизированные утилиты
import { useTranslations } from 'next-intl';
// Тип для пространства имён
type HomePageKeys = keyof IntlMessages['HomePage'];
// = "title" | "subtitle" | "greeting" | "itemsInCart"
// Функция-обёртка с типизацией
function useTypedTranslations<T extends keyof IntlMessages>(namespace: T) {
return useTranslations(namespace);
}

При статической генерации сайта нужно сообщить Next.js обо всех возможных локалях:

app/[locale]/page.tsx
import { locales } from '@/i18n';
// Генерируем статические пути: /en, /ru, /de
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
// app/[locale]/blog/[slug]/page.tsx
import { locales } from '@/i18n';
import { getAllPosts } from '@/lib/posts';
// Комбинируем локали и слаги постов
export async function generateStaticParams() {
const posts = await getAllPosts();
return locales.flatMap((locale) =>
posts.map((post) => ({
locale,
slug: post.slug,
}))
);
}
// Результат: /en/blog/hello, /ru/blog/hello, /de/blog/hello, ...

Для SEO важно указать поисковикам все версии страниц:

// app/[locale]/layout.tsx — добавляем alternates в metadata
import { locales } from '@/i18n';
export async function generateMetadata({
params: { locale },
}: {
params: { locale: string };
}) {
const baseUrl = 'https://example.com';
return {
alternates: {
canonical: `${baseUrl}/${locale}`,
languages: Object.fromEntries(
locales.map((loc) => [loc, `${baseUrl}/${loc}`])
),
},
};
}
// Генерирует:
// <link rel="canonical" href="https://example.com/ru" />
// <link rel="alternate" hreflang="en" href="https://example.com/en" />
// <link rel="alternate" hreflang="ru" href="https://example.com/ru" />
// <link rel="alternate" hreflang="de" href="https://example.com/de" />

// Rich text — HTML в переводах!
// messages/en.json:
// "terms": "I agree to the <link>Terms of Service</link>"
const t = useTranslations('Legal');
t.rich('terms', {
link: (chunks) => (
<a href="/terms" className="underline">
{chunks}
</a>
),
});
// ===
// Переводы в серверных actions
import { getTranslations } from 'next-intl/server';
async function createUser(formData: FormData) {
'use server';
const t = await getTranslations('Validation');
const email = formData.get('email');
if (!email) {
return { error: t('emailRequired') };
}
}
// ===
// useMessages — получить все сообщения
import { useMessages } from 'next-intl';
function DebugTranslations() {
const messages = useMessages();
return <pre>{JSON.stringify(messages, null, 2)}</pre>;
}

Ошибка │ Решение
────────────────────────────────────┼──────────────────────────────────────────
Забыл NextIntlClientProvider │ Обернуть в layout.tsx
useTranslations в Server Component │ Использовать getTranslations (async)
Нет ключа в одном из языков │ TypeScript + строгие типы спасут
Неправильные plural rules │ Тестировать с числами 0, 1, 2, 5, 11, 21
Хардкод направления (left/right) │ Использовать CSS логические свойства
Забыл generateStaticParams │ Локали не будут статически генерированы
Middleware не настроен │ Переходы между локалями не работают

Библиотека │ App Router │ Server Comp │ TypeScript │ Plural │ Размер
──────────────┼────────────┼─────────────┼────────────┼────────┼────────
next-intl │ ✅ Лучшая │ ✅ Нативно │ ✅ Отлично │ ✅ ICU │ ~35kb
react-i18next │ ⚠️ Сложно │ ❌ Нет │ ✅ Хорошо │ ✅ Да │ ~25kb
next-i18next │ ❌ Pages │ ❌ Нет │ ✅ Хорошо │ ✅ Да │ ~20kb
lingui │ ✅ Да │ ✅ Да │ ✅ Отлично │ ✅ Да │ ~15kb

Вывод: Для Next.js App Router — next-intl без вопросов! 🏆


✅ Установлен next-intl
✅ Созданы messages/*.json для каждого языка
✅ Настроен i18n.ts с locales и defaultLocale
✅ Настроен middleware.ts
✅ Создана папка app/[locale]/
✅ Layout обёрнут в NextIntlClientProvider
✅ generateStaticParams возвращает все локали
✅ TypeScript типизация через IntlMessages
✅ Plural rules проверены для всех языков
✅ RTL поддержка (если нужна)
✅ hreflang теги для SEO
✅ Компонент переключения языков
✅ .env содержит DEFAULT_LOCALE

Поздравляю! Теперь твоё приложение говорит на языке пользователя! 🌍🎉