Правильная локализация в 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.jsonru.json
app/[locale]/layout.tsxhtml lang, NextIntlClientProviderpage.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для дат и денег, никаких склеенных строк.