webroad.online
  1. 1Веб
  2. 2HTML
  3. 3CSS
  4. 4JavaScript
  5. 5TypeScript
  6. 6Git
  7. 7Инструменты
  8. 8React
  9. 9Стейт-менеджмент
  10. 10Next.js
  11. 11Формы
  12. 12Данные и бэкенд
  13. 13SEO
  14. 14Tailwind CSS
  15. 15Анимации
  16. 16Тестирование
  17. 17Архитектура
Next.js · Урок 12 из 13

Правильная локализация в Next.js с next-intl

Маршруты [locale], next-intl, сообщения ICU, форматирование через Intl и hreflang.

Обновлено

i18n и l10n

  • i18n (internationalization: 18 букв между «i» и «n») — подготовка кода к нескольким языкам: тексты лежат в файлах сообщений, а не в JSX, а даты и цены проходят через Intl.
  • l10n (localization) — добавление конкретного языка: перевод сообщений, формат даты, валюта, формы множественного числа.

i18n делается один раз. l10n — для каждого нового языка, обычно без изменений в коде.

Где хранится язык

Стратегия Пример Для SEO
префикс в URL example.com/ru/ceny лучший вариант: у каждого языка свой URL, его легко индексировать и связать через hreflang
домен example.ru, example.de хорошо, но нужно оплачивать и поддерживать несколько доменов, а авторитет делится между ними
только cookie example.com/pricing плохо: краулер не отправляет cookie и видит только один язык

Префикс — выбор по умолчанию. Прокси выбирает язык только для адресов без префикса:

GET /pricingproxy.ts→Accept-Languageru-RU,ru;q=0.9,en;q=0.7redirect→/ru/pricingapp/[locale] с locale = ru

Файлы настройки

next-intl (v4) — самая популярная библиотека для App Router. Проект выглядит так:

  • i18n/
    • routing.tsdefineRouting: языки и язык по умолчанию
    • request.tsgetRequestConfig: текущий язык и его сообщения
    • navigation.tscreateNavigation: Link, redirect, usePathname
  • messages/
    • en.jsonтексты, сгруппированные по неймспейсам
    • ro.json
    • ru.json
  • app/
    • [locale]/
      • layout.tsxhtml lang, NextIntlClientProvider
      • page.tsx
  • proxy.tscreateMiddleware(routing)
  • next.config.tscreateNextIntlPlugin() подключает request.ts
// i18n/routing.ts
export const routing = defineRouting({ locales: ['en', 'ro', 'ru'], defaultLocale: 'en' })

// i18n/request.ts
import * as rootParams from 'next/root-params'

export default getRequestConfig(async () => {
  const locale = await rootParams.locale()             // сегмент [locale]
  if (!hasLocale(routing.locales, locale)) notFound()
  return { locale, messages: (await import(`../messages/${locale}.json`)).default }
})

// proxy.ts
export default createMiddleware(routing)
export const config = { matcher: '/((?!api|_next|_vercel|.*\\..*).*)' }

Layout языка

// app/[locale]/layout.tsx
export function generateStaticParams() {
  return routing.locales.map(locale => ({ locale }))     // /en, /ro, /ru собираются при билде
}

export default async function LocaleLayout({ children, params }: LayoutProps<'/[locale]'>) {
  const { locale } = await params
  if (!hasLocale(routing.locales, locale)) notFound()     // /xx → 404, а не ошибка
  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider>{children}</NextIntlClientProvider>
      </body>
    </html>
  )
}

setRequestLocale. Раньше для статического рендеринга нужно было вызывать setRequestLocale(locale) в каждом layout и на каждой странице. Теперь документация next-intl говорит: если request.ts читает язык из next/root-params (доступен по умолчанию с Next 16.3), он больше не нужен, а интеграция с cacheComponents становится лучше. generateStaticParams по-прежнему обязателен. На более старых версиях Next setRequestLocale всё ещё нужен.

Переводы на сервере и на клиенте

// Server или Client Component (не async)
const t = useTranslations('Cart')
return <button>{t('add')}</button>

// асинхронный серверный код: actions, route handlers, generateMetadata
export async function generateMetadata({ params }: PageProps<'/[locale]/pricing'>) {
  const { locale } = await params
  const t = await getTranslations({ locale, namespace: 'Pricing' })
  return { title: t('title') }
}

Сообщения ICU

{
  "greeting": "Привет, {name}!",
  "items": "{count, plural, =0 {Корзина пуста} one {# товар} few {# товара} many {# товаров} other {# товара}}",
  "invited": "{gender, select, female {Она пригласила вас} male {Он пригласил вас} other {Вас пригласили}}",
  "terms": "Я принимаю <link>условия</link>."
}
t('greeting', { name })
t('items', { count: 21 })                                       // «21 товар»
t.rich('terms', { link: chunks => <Link href="/terms">{chunks}</Link> })

У каждого языка свои формы множественного числа: в английском 2 (one, other), в румынском 3 (one, few, other), в русском 4 (one, few, many, other): 1 товар, 2 товара, 5 товаров, 21 товар. Переводчик прописывает все формы в сообщении, а код остаётся прежним. Классическая ловушка — count === 1 ? 'товар' : 'товаров': для 2 и 21 это неверно.

Даты, числа, относительное время

const format = useFormatter()                   // в async-коде на сервере: await getFormatter()
format.dateTime(post.date, { dateStyle: 'long' })                  // 9 октября 2026 г.
format.number(price, { style: 'currency', currency: 'EUR' })       // 1 234,50 €
format.relativeTime(post.updatedAt, now)                           // 3 дня назад

Типобезопасные сообщения

// global.ts
declare module 'next-intl' {
  interface AppConfig {
    Locale: (typeof routing.locales)[number]
    Messages: typeof messages                   // import messages from './messages/en.json'
  }
}

Теперь t('Cart.ad') — ошибка TypeScript, а ключи подсказывает автодополнение.

Локализованные ссылки

// i18n/navigation.ts
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing)

<Link href="/pricing"> ведёт на /ru/pricing, если вы на /ru, а <Link href={usePathname()} locale="en"> переключает язык на той же странице. Через pathnames в defineRouting можно перевести и сам URL: '/pricing': { en: '/pricing', ro: '/preturi', ru: '/ceny' }.

Многоязычное SEO

  • Один язык = один URL. Никогда не отдавайте два языка по одному адресу.
  • hreflang на каждой версии, взаимно, плюс x-default. В Next — через alternates.languages; next-intl также отправляет заголовок link с той же информацией.
  • <html lang={locale}>, а не жёстко прописанный lang="en".
  • Не перенаправляйте автоматически адреса с префиксом по Accept-Language или IP. Googlebot сканирует в основном из США и увидел бы только английский. Редирект допустим только на /.
alternates: {
  canonical: `/${locale}/pricing`,
  languages: { en: '/en/pricing', ro: '/ro/pricing', ru: '/ru/pricing', 'x-default': '/en/pricing' },
}

Подробнее — в Техническом SEO и Метаданных и SEO.

Организация сообщений

  • Неймспейсы по функциональности: Cart, Checkout, Pricing, а не один плоский уровень с 500 ключами.
  • Сначала хватит одного файла на язык. Когда он разрастётся, разбейте его по фичам (messages/ru/cart.json) и объединяйте в request.ts.
  • Процесс: разработчик добавляет ключ на исходном языке, скрипт или сервис переводов показывает, чего не хватает в остальных, а CI падает, если ключа нет.

Частые ошибки

Ошибка Правильно
текст прямо в JSX любой видимый текст берётся из t()
t('hello') + ' ' + name t('greeting', { name }), переменная внутри сообщения
next/link с href="/pricing" Link из i18n/navigation, он сохраняет язык
провайдер отправляет на клиент все сообщения переводите в Server Components или передавайте провайдеру только нужные неймспейсы
count === 1 ? 'товар' : 'товаров' {count, plural, ...} или Intl.PluralRules

А без библиотеки?

Небольшой сайт может обойтись одним Next: сегмент [locale], proxy.ts для редиректа с /, JSON-словари, загружаемые на сервере, и Intl для форматирования. Именно так сделан сам webroad.online. next-intl окупается, когда много текста в Client Components, есть плюралы и сообщения ICU, переведённые URL или команда, которой нужны ключи, проверяемые TypeScript.

Коротко

  • Язык живёт в URL (/ru/...), прокси выбирает язык только для /, у каждой версии взаимный hreflang и правильный <html lang>.
  • useTranslations в компонентах, getTranslations в асинхронном серверном коде и в generateMetadata, один NextIntlClientProvider в layout.
  • Сообщения ICU для переменных и плюралов, Intl для дат и денег, никаких склеенных строк.

Официальные источники

Упражнения

Была ли страница полезной?

Один клик — без регистрации.