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 дня, письменный отчёт с проверкой каждого пункта выше). Контакт.