Skip to main content

Інтернаціоналізація (i18n) в Next.js

Інтернаціоналізація (i18n) в Next.js - це шаблон маршрутизації, де код локалі живе в URL, а middleware автоматично визначає мову користувача і перенаправляє на потрібну версію сторінки.

Теорія

TL;DR

  • Локаль живе в URL: /en/about, /de/about - одна сторінка, різні мови
  • App Router використовує динамічний сегмент [locale]; Pages Router - next.config.js (вони не сумісні)
  • Middleware читає заголовок Accept-Language або cookie, потім робить redirect до рендеру сторінки
  • next-intl - стандартна бібліотека для App Router: підтримує множину (pluralization), RTL та RSC
  • Якщо додаток однієї мови - i18n-маршрутизація не потрібна, URL-префікси лише ускладнюють структуру

Швидкий приклад

Мінімальне налаштування: папка [locale] і middleware, що перенаправляє / на /en або /de.

tsx
// app/[locale]/page.tsx export default function Home({ params }: { params: { locale: string } }) { return <h1>Hello from {params.locale.toUpperCase()}</h1>; // /en → "Hello from EN", /de → "Hello from DE" } // middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; const locales = ['en', 'de']; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const hasLocale = locales.some( (l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}` ); if (hasLocale) return NextResponse.next(); const locale = request.headers.get('accept-language')?.includes('de') ? 'de' : 'en'; return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url)); } export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'], };

Патерн matcher у config не дає middleware чіпати файли /_next/static і API-маршрути. Без нього статичні ресурси повертають 404.

Як працює маршрутизація

Кожна сторінка живе під app/[locale]/. Коли приходить запит, middleware перехоплює його до того, як Next.js вирішує маршрут. Спочатку перевіряється cookie, потім Accept-Language, потім використовується дефолтна локаль. URL переписується або відбувається redirect з префіксом локалі, і [locale] стає параметром доступним у кожній сторінці та layout-і цієї папки.

App Router і Pages Router по-різному підходять до i18n. Pages Router використовує блок i18n в next.config.js. App Router повністю ігнорує цей конфіг - йому потрібна папка [locale] і middleware. Якщо змішати обидва підходи, визначення локалі ламається без явних помилок.

Налаштування next-intl

Для будь-чого складнішого за демо-проект краще взяти next-intl. Бібліотека додає множину (pluralization), форматування дат і чисел, підтримку RSC - те, чого прості JSON-об'єкти не дають.

tsx
// messages/en.json { "Dashboard": { "title": "User Dashboard", "stats": "{users} active users" } } // app/[locale]/layout.tsx import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; export default async function LocaleLayout({ children, params, }: { children: React.ReactNode; params: Promise<{ locale: string }>; }) { const { locale } = await params; const messages = await getMessages(); return ( <html lang={locale}> <body> <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> </body> </html> ); }
tsx
// app/[locale]/dashboard/page.tsx import { useTranslations } from 'next-intl'; import { notFound } from 'next/navigation'; export default function Dashboard({ params: { locale } }: { params: { locale: string } }) { if (!['en', 'de'].includes(locale)) notFound(); const t = useTranslations('Dashboard'); return ( <div> <h1>{t('title')}</h1> <p>{t('stats', { users: 42 })}</p> {/* "42 active users" */} </div> ); }

Виклик notFound() для невідомих локалей легко пропустити на початку. Без нього запит на /xx/dashboard відрендерить сторінку з порожньою локаллю замість 404.

Перемикач мови

tsx
'use client'; import { usePathname, useRouter } from 'next/navigation'; export function LanguageSwitcher() { const pathname = usePathname(); const router = useRouter(); const switchLocale = (newLocale: string) => { const segments = pathname.split('/'); segments[1] = newLocale; // замінити сегмент локалі router.push(segments.join('/')); }; return ( <div> <button onClick={() => switchLocale('en')}>EN</button> <button onClick={() => switchLocale('de')}>DE</button> </div> ); }

Коли використовувати i18n-маршрутизацію

  • Багатомовний SaaS або e-commerce: subpath-маршрутизація (/en/shop) краща за окремі домени з точки зору SEO, бо cookie та сесії спільні для всіх локалей
  • Одномовний додаток: звичайна маршрутизація Next.js, папка [locale] не потрібна
  • Статичний експорт (next export): i18n-маршрутизацію краще не використовувати. Rewrites у middleware залежать від Node.js runtime; використовуй generateStaticParams() для кожної локалі замість цього
  • Проект на Pages Router: залиш масив i18n.locales в next.config.js, не мігруй лише заради i18n

Типові помилки

Помилка 1: Немає matcher у конфігурації middleware.

tsx
// Неправильно - rewrite /_next/static → /en/_next/static (ресурси повертають 404) export function middleware(request: NextRequest) { return NextResponse.rewrite(new URL('/en' + request.nextUrl.pathname, request.url)); }

Рішення: завжди додавай config.matcher, щоб виключити _next, api і статичні файли.

Помилка 2: Хардкод перекладів у компонентах.

tsx
// Неправильно <h1>{locale === 'de' ? 'Hallo' : 'Hello'}</h1>

Це ламається на множині, RTL-мовах і будь-чому довшому за 10-15 рядків. Використовуй next-intl з ICU-форматом: {count, plural, one {# user} other {# users}}.

Помилка 3: Ключ i18n в next.config.js при використанні App Router.

App Router ігнорує цей ключ повністю. Сторінка рендериться, але визначення локалі та redirects не відбуваються. Видали ключ з конфігу і переходь на папки [locale] з middleware.

Помилка 4: Відсутність notFound() для невалідних локалей.

Без валідації будь-який некоректний URL відрендерить сторінку з порожньою локаллю. Перевіряй на початку компонента і одразу викликай notFound().

Помилка 5: Втрата локалі при навігації через <Link>.

tsx
// Неправильно - /products втрачає префікс локалі <Link href="/products">Продукти</Link> // Правильно - Link з підтримкою локалі від next-intl import { Link } from '@/i18n/routing'; <Link href="/products">Продукти</Link>

Де зустрічається в реальних проектах

  • Vercel.com використовує next-intl для документації з 30+ локалями через middleware
  • Shopify Hydrogen - subpath i18n з domain mapping як запасний варіант
  • Для RTL-мов (арабська, іврит): додай dir="rtl" до <html> на основі локалі, використовуй CSS logical properties (margin-inline-start замість margin-left), хелпер direction() від next-intl визначає напрямок автоматично

Follow-up питання

Q: Як Next.js визначає локаль без middleware?
A: Ніяк. Параметр [locale] - це просто сегмент URL. Без middleware нічого не встановлює його автоматично. Middleware - єдине місце де читається Accept-Language і cookie для логіки redirect.

Q: Subpath чи domain-стратегія - що краще для SEO?
A: Subpath (/en/about) має спільні cookie, сесії та link equity між локалями. Domain-стратегія (en.example.com) потребує окремого DNS і matcher у middleware для кожного хоста. Subpath простіший для більшості випадків. Домени мають сенс лише якщо потрібна повністю окрема аналітика або бренд для кожного регіону.

Q: Що ламається при next export з i18n?
A: Rewrites у middleware залежать від Node.js runtime. Статичний експорт прибирає сервер, тому визначення локалі не відбувається. Рішення - generateStaticParams() для попереднього рендеру кожної локалі під час збірки, або деплой на платформу з підтримкою runtime (Vercel, будь-який Node.js-хост).

Q: Як обробляти RTL-мови?
A: Встанови dir="rtl" на <html> на основі локалі. Використовуй CSS logical properties (padding-inline, margin-block) замість направлених (padding-left, margin-right). next-intl має хелпер direction() що повертає "ltr" або "rtl" зі строки локалі.

Q: (Senior) У гібридному додатку з Pages Router і App Router - як ділити стан локалі між ними?
A: Найчистіший підхід - root middleware що встановлює cookie NEXT_LOCALE. Pages Router читає локаль через визначення з next.config.js, App Router - через params. Вони можуть перетинатися якщо middleware обробляє обидва routing-префікси. На практиці гібридів варто уникати - краще мігрувати повністю або тримати i18n лише в тому router, якому належать локалізовані сторінки.

Приклади

Базове налаштування: структура папок і параметр локалі

Мінімум для роботи сторінок з підтримкою локалі в App Router, без сторонніх бібліотек.

tsx
// app/[locale]/layout.tsx export default function LocaleLayout({ children, params, }: { children: React.ReactNode; params: { locale: string }; }) { return ( <html lang={params.locale}> <body>{children}</body> </html> ); } // app/[locale]/page.tsx export default function Home({ params }: { params: { locale: string } }) { const greetings: Record<string, string> = { en: 'Welcome', de: 'Willkommen', es: 'Bienvenido', }; return <h1>{greetings[params.locale] ?? greetings.en}</h1>; // /en → "Welcome", /de → "Willkommen" }

Без бібліотек. Локаль - просто URL-параметр, переклади - звичайні об'єкти. Підходить для 3-5 рядків тексту. Далі - next-intl.

Дашборд з next-intl і валідацією локалі

Реальний сценарій: дашборд що валідує локаль, завантажує JSON-повідомлення і рендерить перекладений контент з інтерполяцією та множиною.

tsx
// messages/en.json { "Dashboard": { "title": "User Dashboard", "activeUsers": "{count, plural, one {# active user} other {# active users}}", "lastSeen": "Last seen {time}" } } // app/[locale]/dashboard/page.tsx import { useTranslations } from 'next-intl'; import { notFound } from 'next/navigation'; const supportedLocales = ['en', 'de', 'es']; export default function Dashboard({ params: { locale } }: { params: { locale: string } }) { if (!supportedLocales.includes(locale)) notFound(); const t = useTranslations('Dashboard'); return ( <main> <h1>{t('title')}</h1> <p>{t('activeUsers', { count: 1 })}</p> {/* "1 active user" */} <p>{t('activeUsers', { count: 42 })}</p> {/* "42 active users" */} <p>{t('lastSeen', { time: '2 hours ago' })}</p> </main> ); }

ICU-формат обробляє множину автоматично. Патерн {count, plural, one {...} other {...}} - без if/else у компонентах.

Розширений кейс: приховування префіксу дефолтної локалі

Деякі команди хочуть /about замість /en/about для мови за замовчуванням. Middleware обробляє логіку redirect без показу дефолтного префіксу в URL.

tsx
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; const locales = ['en', 'de'] as const; function getLocale(request: NextRequest): 'en' | 'de' { const cookie = request.cookies.get('locale')?.value; if (cookie === 'de') return 'de'; return request.headers.get('accept-language')?.includes('de') ? 'de' : 'en'; } export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const pathnameHasLocale = locales.some( (l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}` ); if (!pathnameHasLocale) { const locale = getLocale(request); // /about → /en/about (або /de/about для німецького браузера) return NextResponse.redirect( new URL(`/${locale}${pathname === '/' ? '' : pathname}`, request.url) ); } return NextResponse.next(); } export const config = { matcher: ['/((?!api|_next|favicon.ico).*)'], };

Один edge case: якщо slug сторінки збігається з кодом локалі (наприклад, стаття /de про Німеччину), middleware перехопить його як локаль. Використовуй вкладені динамічні сегменти [locale]/blog/[slug], щоб уникнути колізії.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?