20. Интернационализация (i18n)
21. Интернационализация (i18n) 🌍
Заголовок раздела «21. Интернационализация (i18n) 🌍»Представь, что ты открываешь сайт на китайском языке. Ты ничего не понимаешь, закрываешь его и уходишь. А теперь представь — тот же сайт, но он говорит с тобой на твоём языке, показывает дату в привычном формате, и даже цены в рублях. Магия? Нет — это интернационализация (i18n)! 🎩
i18n — сокращение от “internationalization” (18 букв между “i” и “n”). Это процесс адаптации приложения для разных языков и регионов.
Интернационализация = i18nЛокализация = l10n (10 букв между "l" и "n")
i18n — это инфраструктура (как сделать переводимым)l10n — это контент (сами переводы для каждого языка)🤔 Зачем нужна i18n?
Заголовок раздела «🤔 Зачем нужна i18n?»Аналогия: Представь международный аэропорт. В нём есть указатели на нескольких языках, объявления переводятся, деньги принимают разные, время показывают по местному. Без этого — хаос! Твоё веб-приложение — тот же аэропорт, только виртуальный.
Реальные цифры:
- 75% пользователей предпочитают сайты на родном языке
- 60% никогда не покупают на сайтах не на своём языке
- Добавление 10 языков может удвоить аудиторию
Проблема без i18n │ Решение с i18n───────────────────────────────┼────────────────────────────────Все строки захардкожены в JSX │ Ключи переводов + JSON файлыДаты в американском формате │ Intl.DateTimeFormat по локалиЧисла без разделителей │ Intl.NumberFormat по локалиURL одинаковый для всех │ /en/about, /ru/about, /de/aboutНет SEO для других языков │ hreflang теги, отдельные URL📦 Установка next-intl
Заголовок раздела «📦 Установка next-intl»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'.
📝 Файлы переводов (messages/)
Заголовок раздела «📝 Файлы переводов (messages/)»Сначала создаём 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" }}{ "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": "Попробовать снова" }}{ "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
Заголовок раздела «⚙️ Конфигурация i18n.ts»// 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 для определения локали»Middleware — это “привратник” твоего приложения. Он встречает каждый запрос и решает: какую локаль использовать?
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") → пропускаем🏗️ Layout с NextIntlClientProvider
Заголовок раздела «🏗️ Layout с NextIntlClientProvider»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в клиентских компонентах не будет работать!
🖥️ getTranslations в Server Components
Заголовок раздела «🖥️ getTranslations в Server Components»Server Components могут использовать await getTranslations() — это самый быстрый способ, переводы приходят прямо с сервера:
// app/[locale]/page.tsx — Server Componentimport { 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> );}💻 useTranslations в Client Components
Заголовок раздела «💻 useTranslations в Client Components»// 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> );}🔄 RTL поддержка (Арабский/Иврит)
Заголовок раздела «🔄 RTL поддержка (Арабский/Иврит)»Некоторые языки читаются справа налево (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> );}🔗 Компонент переключателя языков
Заголовок раздела «🔗 Компонент переключателя языков»'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> );}🏷️ TypeScript типизация переводов
Заголовок раздела «🏷️ TypeScript типизация переводов»Это killer feature next-intl! Ты получаешь автодополнение для ключей переводов:
// Шаг 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'); // ✅ OKt('subtitle'); // ✅ OKt('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);}🚀 generateStaticParams для i18n
Заголовок раздела «🚀 generateStaticParams для i18n»При статической генерации сайта нужно сообщить Next.js обо всех возможных локалях:
import { locales } from '@/i18n';
// Генерируем статические пути: /en, /ru, /deexport function generateStaticParams() { return locales.map((locale) => ({ locale }));}
// app/[locale]/blog/[slug]/page.tsximport { 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 с hreflang тегами
Заголовок раздела «🔍 SEO с hreflang тегами»Для SEO важно указать поисковикам все версии страниц:
// app/[locale]/layout.tsx — добавляем alternates в metadataimport { 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" />🎛️ Продвинутые возможности next-intl
Заголовок раздела «🎛️ Продвинутые возможности next-intl»// 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> ),});
// ===
// Переводы в серверных actionsimport { 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.tsxuseTranslations в Server Component │ Использовать getTranslations (async)Нет ключа в одном из языков │ TypeScript + строгие типы спасутНеправильные plural rules │ Тестировать с числами 0, 1, 2, 5, 11, 21Хардкод направления (left/right) │ Использовать CSS логические свойстваЗабыл generateStaticParams │ Локали не будут статически генерированыMiddleware не настроен │ Переходы между локалями не работают📊 Сравнение библиотек i18n для Next.js
Заголовок раздела «📊 Сравнение библиотек i18n для Next.js»Библиотека │ App Router │ Server Comp │ TypeScript │ Plural │ Размер──────────────┼────────────┼─────────────┼────────────┼────────┼────────next-intl │ ✅ Лучшая │ ✅ Нативно │ ✅ Отлично │ ✅ ICU │ ~35kbreact-i18next │ ⚠️ Сложно │ ❌ Нет │ ✅ Хорошо │ ✅ Да │ ~25kbnext-i18next │ ❌ Pages │ ❌ Нет │ ✅ Хорошо │ ✅ Да │ ~20kblingui │ ✅ Да │ ✅ Да │ ✅ Отлично │ ✅ Да │ ~15kbВывод: Для Next.js App Router — next-intl без вопросов! 🏆
🎯 Итог: Чек-лист i18n
Заголовок раздела «🎯 Итог: Чек-лист i18n»✅ Установлен 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Поздравляю! Теперь твоё приложение говорит на языке пользователя! 🌍🎉