Добавление интернационализации в проект на Next.js App Router — тот тип работы, который выглядит тривиально для первой локали и дорожает к третьей. Большинство гайдов покрывают happy path: поставь next-intl, добавь middleware, брось переводы в messages/*.json, готово.
Happy path работает. Продакшн-путь имеет шесть конкретных граблей, на которые наступишь. Я наступил на каждые, отгружая трёхъязычные сайты (EN/RU/UK), и вот как каждые проявляются и как я их избегаю теперь.
1. Петля редиректов middleware
Что ломается: Переходишь на / и получаешь бесконечные редиректы. Или /en редиректит на /, который редиректит на /en. Или только незалогиненные пользователи видят петлю.
Почему: Middleware next-intl пытается прицепить локаль к каждому запросу. Если другой middleware (auth, A/B-тестирование, кастомные редиректы) тоже переписывает или редиректит, и они не компонуются чисто — получаешь петлю.
Классический случай: auth-middleware редиректит неаутентифицированных с / на /login, который с префиксом локали, что триггерит i18n-middleware, который ставит куку, что меняет значение / на следующем запросе, что ретриггерит auth-middleware.
Фикс: Цепляй middleware'ы явно вместо параллельного запуска.
// middleware.ts
import createIntlMiddleware from 'next-intl/middleware'
import { authMiddleware } from './lib/auth'
const intlMiddleware = createIntlMiddleware({
locales: ['en', 'ru', 'uk'],
defaultLocale: 'en',
localePrefix: 'as-needed',
})
export default async function middleware(request) {
// Сначала i18n, чтобы нормализовать URL
const intlResponse = intlMiddleware(request)
// Если i18n хочет редиректнуть — позволь, не проверяй auth первым
if (intlResponse.status === 307 || intlResponse.status === 302) {
return intlResponse
}
// Теперь auth на нормализованном URL
return authMiddleware(request) ?? intlResponse
}
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
}
Порядок важен. i18n идёт первым для нормализации URL. Auth выполняется на нормализованном URL. Всё, что может редиректнуть, выполняется после i18n, чтобы редиректы попадали на правильно-префиксированные пути.
2. Canonical-теги и hreflang — SEO-подстава
Что ломается: Google индексирует /about и /en/about как отдельные страницы с дублированным контентом. Ранкинг для обоих хуже, чем был бы для любого по отдельности.
Почему: С localePrefix: 'as-needed' дефолтная локаль не имеет префикса. Поэтому /about и /en/about рендерят идентичный контент с разными URL. Без явных canonical/hreflang Google видит две версии одной страницы.
Фикс: Эмитить <link rel="canonical">, указывающий на версию без префикса, и <link rel="alternate" hreflang="X"> для каждой локали.
// app/[locale]/layout.tsx
export async function generateMetadata({ params }) {
const { locale } = await params
const path = '' // или вычисли из маршрута
return {
alternates: {
canonical: `https://dimaver6.com${path}`,
languages: {
en: `https://dimaver6.com${path}`,
ru: `https://dimaver6.com/ru${path}`,
uk: `https://dimaver6.com/uk${path}`,
'x-default': `https://dimaver6.com${path}`,
},
},
}
}
Три тонкости:
x-defaultговорит Google, что показывать, когда локаль неоднозначна (поиск из страны, для которой нет локали). Направляй на дефолтный язык.- hreflang реципрокален. Если EN ссылается на RU, RU должен ссылаться обратно на EN. Асимметричный hreflang Google игнорирует.
- Canonical указывает на URL, который ты хочешь индексировать. Если хочешь
/about, а не/en/about— canonical с обоих указывает на/about.
Тестируй через URL Inspection tool в Google Search Console. Если «user-declared canonical» и «Google-selected canonical» не совпадают — у тебя баг.
3. Крайний случай сериализации RSC, который молча съедает переводы
Что ломается: Серверный компонент рендерит перевод. В dev работает. В продакшне перевод рендерится пустым или как сам ключ. Ошибки не видно; страница просто отгружается сломанной.
Почему: Этот сильно укусил меня. Когда вызываешь useTranslations() в клиентском компоненте — работает нормально. Когда вызываешь getTranslations() в серверном компоненте и передаёшь результат как prop в клиентский компонент — возвращаемый функцией объект namespace может не сериализоваться через границу RSC, если несёт функции вместо строк.
В итоге prop, который выглядит правильно в dev (где гидратация заполняет пробелы), фактически пустой в продакшн-HTML.
Фикс: Материализуй нужные строки на сервере, передавай чистые строки в клиентский компонент.
// Серверный компонент — ДЕЛАЙ ТАК
import { getTranslations } from 'next-intl/server'
export default async function Page() {
const t = await getTranslations('hero')
return (
<ClientHero
title={t('title')} // чистая строка, сериализуется чисто
subtitle={t('subtitle')} // чистая строка
ctaLabel={t('cta')} // чистая строка
/>
)
}
// НЕ ТАК — передавать `t` через границу хрупко
export default async function Page() {
const t = await getTranslations('hero')
return <ClientHero t={t} /> // не делай так
}
Если нужно, чтобы клиентский компонент вызывал t(...) динамически (например, с ключом из пользовательского ввода), используй NextIntlClientProvider на более высоком уровне и вызывай useTranslations() на клиентской стороне — не пытайся передавать функцию-транслятор через границу.
4. Динамические route params и notFound() для неизвестных локалей
Что ломается: Кто-то набирает /fr/about в URL. Приложение не поддерживает французский. Вместо чистой 404 рендерится ломаный HTML, потому что локаль невалидна, но страница всё равно пытается отрендериться.
Почему: [locale] catch в Next — буквальный: он поймает что угодно в этом слоте, включая локали, которые ты не поддерживаешь. Если не отсечёшь рано — рендеришь с undefined-переводами.
Фикс: Валидируй на уровне layout и вызывай notFound() немедленно.
// app/[locale]/layout.tsx
import { notFound } from 'next/navigation'
const SUPPORTED_LOCALES = ['en', 'ru', 'uk'] as const
type Locale = (typeof SUPPORTED_LOCALES)[number]
function isSupportedLocale(locale: string): locale is Locale {
return SUPPORTED_LOCALES.includes(locale as Locale)
}
export default async function LocaleLayout({ children, params }) {
const { locale } = await params
if (!isSupportedLocale(locale)) notFound()
// ...
}
Бонус: generateStaticParams экспортирует валидный набор, Next кеширует их, и неизвестные локали получают 404 на edge вместо пробуждения сервера.
export function generateStaticParams() {
return SUPPORTED_LOCALES.map((locale) => ({ locale }))
}
5. Sitemap + robots по локалям
Что ломается: Sitemap содержит только английские URL. Google не знает, что нужно краулить русский и украинский. Работа по i18n не отображается в Search Console.
Почему: Дефолтные генераторы sitemap в Next.js строят дерево маршрутов из файловой системы — что даёт /[locale]/about один раз, а не /en/about, /ru/about, /uk/about.
Фикс: Разверни локали при генерации sitemap.
// app/sitemap.ts
const LOCALES = ['en', 'ru', 'uk'] as const
const ROUTES = ['', '/blog', '/contact']
export default function sitemap() {
const entries = []
for (const route of ROUTES) {
for (const locale of LOCALES) {
const localePath = locale === 'en' ? route : `/${locale}${route}`
entries.push({
url: `https://dimaver6.com${localePath}`,
lastModified: getLastModified(route), // стабильный, не new Date()
alternates: {
languages: {
en: `https://dimaver6.com${route}`,
ru: `https://dimaver6.com/ru${route}`,
uk: `https://dimaver6.com/uk${route}`,
},
},
})
}
}
return entries
}
Две вещи:
lastModifiedдолжен быть стабильным. Возвращатьnew Date()— менять каждый запрос и ломать кеширование sitemap. Используй даты коммитов или mtime контента.alternates.languagesв sitemap дублирует hreflang-теги. Google кросс-проверяет.
6. Размер файлов сообщений и клиентский бандл
Что ломается: Bundle inspector показывает 90KB JSON-переводов в клиентском бандле для каждой локали, когда ты обслуживаешь одну локаль на сессию.
Почему: Наивные сетапы статически импортируют все messages/*.json и дают роутеру выбрать один в рантайме. Бандлер не может tree-shake, потому что выбор происходит в рантайме.
Фикс: Динамический импорт по ключу локали, выбранный на этапе сборки для каждого маршрута.
// i18n/request.ts
import { getRequestConfig } from 'next-intl/server'
export default getRequestConfig(async ({ locale }) => ({
messages: (await import(`../messages/${locale}.json`)).default,
}))
Шаблонный литерал — несущий. await import('../messages/en.json') статически забандлит только en.json. await import(\../messages/$.json`)` с переменной локали говорит Next сплитить по локалям и включать только подходящую в каждый побайтовый статический бандл.
Я больше писал об этом в посте про Lighthouse 98 — сплиттинг по локалям был одним из крупнейших выигрышей по размеру бандла.
Чеклист i18n, который я прохожу перед запуском
Не отгрузи ничего из выше — получишь работающий сайт, но будешь терять SEO и, возможно, пользователей. Перед тем как пометить любой мультиязычный сайт «готов»:
- Middleware компонуются чисто — нет петель редиректов в комбинации auth + i18n
- Canonical и hreflang эмитятся на каждой странице, реципрокально, с x-default
- Серверные компоненты материализуют строки; нет
t, переданного через границу RSC - Неизвестные локали →
notFound()на уровне layout, не на уровне page - Sitemap разворачивает каждый маршрут × каждую локаль со стабильным
lastModified - Динамические импорты файлов сообщений, подтверждённые в bundle analyzer
- Google Search Console верифицирован для каждого подпути локали
- hreflang спот-чекнут Screaming Frog или аналогичным краулером
Этот чеклист — 10 минут на прогон, и он ловит то, что иначе просочится в прод и останется там на месяцы, пока кто-то не заметит.
Мета-урок
i18n — фича, которая выглядит дешевле всего в описании PR и стоит дороже всего за шесть месяцев. Первая локаль — день. Вторая локаль поднимает грабли middleware и canonical. Третья — грабли sitemap и сериализации RSC. К четвёртой ты уже построил леса, и каждая новая локаль снова — день.
Строй леса до второй локали. Будет выглядеть как overengineering для одной локали. Окупится в момент добавления второй.
Если ты отгружаешь i18n-проект и хочешь продакшн-аудит перед выходом в live — я делаю разовые ревью (€500, оборот 3 дня, письменный отчёт с проверкой каждого пункта выше). Контакт.