Інтернаціоналізація (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.
// 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-об'єкти не дають.
// 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>
);
}// 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.
Перемикач мови
'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.
// Неправильно - 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: Хардкод перекладів у компонентах.
// Неправильно
<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>.
// Неправильно - /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, без сторонніх бібліотек.
// 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-повідомлення і рендерить перекладений контент з інтерполяцією та множиною.
// 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.
// 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], щоб уникнути колізії.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.