Skip to main content
Назад до блогу
8 хв читання

Як правильно відвантажити i18n у Next.js: роутинг, SEO і граблі RSC

Помилки i18n, які проходять код-ревʼю і ламаються у продакшні — петлі middleware, canonical-теги, пастки hreflang і крайовий випадок серіалізації RSC, що мовчки зʼїдає переклади.

next.jsi18nseo

Додавання інтернаціоналізації до проєкту на 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}`,
      },
    },
  }
}

Три тонкощі:

Тестуй через 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
}

Дві речі:

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 і, можливо, користувачів. Перед тим як позначити будь-який мультимовний сайт «готовий»:

Цей чекліст — 10 хвилин на прогін, і він ловить те, що інакше просочиться у прод і залишиться там на місяці, поки хтось не помітить.

Мета-урок

i18n — фіча, що виглядає найдешевше в описі PR і коштує найдорожче за шість місяців. Перша локаль — день. Друга локаль піднімає граблі middleware і canonical. Третя — граблі sitemap і серіалізації RSC. До четвертої ти вже побудував ліси, і кожна нова локаль знову — день.

Будуй ліси до другої локалі. Буде виглядати як overengineering для однієї локалі. Окупиться в момент додавання другої.


Якщо ти відвантажуєш i18n-проєкт і хочеш продакшн-аудит перед виходом у live — я роблю разові ревʼю (€500, оборот 3 дні, письмовий звіт із перевіркою кожного пункту вище). Контакт.