Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Інтернаціоналізація (i18n) в Next.js». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Інтернаціоналізація (i18n) в Next.js** перенаправляє користувачів на URL з префіксом локалі - `/en/about` або `/de/about` - через динамічний сегмент `[locale]` і middleware, що читає заголовки `Accept-Language` або cookie. ```tsx // middleware.ts визначає мову і робить redirect const locale = request.headers.get('accept-language')?.includes('de') ? 'de' : 'en'; return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url)); // Результат: /about → /en/about або /de/about залежно від мови браузера ``` **Ключове:** i18n в App Router потребує папок `[locale]` і middleware. Ключ `i18n` в `next.config.js` призначений лише для Pages Router і не впливає на App Router.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Інтернаціоналізація (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]`, щоб уникнути колізії.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.