Додавання інтернаціоналізації до проєкту на 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 дні, письмовий звіт із перевіркою кожного пункту вище). Контакт.