Пошук уроків, статей та іншого контенту
Створіть багатомовну структуру URL із локалями, префіксами маршрутів і перемиканням мови.
Багатомовний сайт може використовувати локаль як частину URL:
/uk — головна сторінка українською;
/en — головна сторінка англійською;
/uk/about — сторінка «Про нас» українською;
/en/about — та сама сторінка англійською.
Такий підхід має кілька переваг:
кожна мовна версія має власну адресу;
сторінками можна ділитися та індексувати їх окремо;
перезавантаження сторінки не скидає вибрану мову;
URL однозначно визначає мову інтерфейсу.
У цьому прикладі використовується App Router і динамічний сегмент [locale].
Створимо таку структуру:
app/
├── [locale]/
│ ├── about/
│ │ └── page.tsx
│ ├── layout.tsx
│ └── page.tsx
├── layout.tsx
└── page.tsx
components/
└── language-switcher.tsx
i18n/
├── config.ts
└── dictionaries.ts
middleware.tsСегмент [locale] означає, що Next.js сприйматиме першу частину URL як параметр маршруту:
/uk → locale = "uk"
/en → locale = "en"
/uk/about → locale = "uk"Спочатку винесемо список доступних локалей у спільний модуль.
i18n/config.tsexport const locales = ["uk", "en"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "uk";
export function isLocale(value: string): value is Locale {
return locales.includes(value as Locale);
}as const дає змогу TypeScript вивести тип:
type Locale = "uk" | "en";Функція isLocale перевіряє, чи належить значення до списку підтримуваних локалей.
URL визначає локаль, але самі тексти краще зберігати окремо.
i18n/dictionaries.tsimport type { Locale } from "./config";
type Dictionary = {
homeTitle: string;
homeDescription: string;
aboutTitle: string;
aboutDescription: string;
switchLanguage: string;
};
const dictionaries: Record<Locale, Dictionary> = {
uk: {
homeTitle: "Головна сторінка",
homeDescription: "Це українська версія сайту.",
aboutTitle: "Про нас",
aboutDescription: "Інформація про нашу команду.",
switchLanguage: "Змінити мову",
},
en: {
homeTitle: "Home page",
homeDescription: "This is the English version of the site.",
aboutTitle: "About us",
aboutDescription: "Information about our team.",
switchLanguage: "Change language",
},
};
export function getDictionary(locale: Locale): Dictionary {
return dictionaries[locale];
}Цей модуль не залежить від компонентів React, тому його можна використовувати в серверних компонентах.
Створимо layout усередині [locale]. Він буде спільним для всіх сторінок конкретної мовної версії.
app/[locale]/layout.tsximport { notFound } from "next/navigation";
import { LanguageSwitcher } from "../../components/language-switcher";
import { getDictionary } from "../../i18n/dictionaries";
import { isLocale, locales } from "../../i18n/config";
type LocaleLayoutProps = {
children: React.ReactNode;
params: Promise<{
locale: string;
}>;
};
export function generateStaticParams() {
return locales.map((locale) => ({
locale,
}));
}
export default async function LocaleLayout({
children,
params,
}: LocaleLayoutProps) {
const { locale } = await params;
if (!isLocale(locale)) {
notFound();
}
const dictionary = getDictionary(locale);
return (
<html lang={locale}>
<body>
<header>
<nav>
<a href={`/${locale}`}>{dictionary.homeTitle}</a>
{" · "}
<a href={`/${locale}/about`}>{dictionary.aboutTitle}</a>
</nav>
<LanguageSwitcher
currentLocale={locale}
label={dictionary.switchLanguage}
/>
</header>
<main>{children}</main>
</body>
</html>
);
}generateStaticParams повідомляє Next.js, що потрібно підготувати маршрути для uk та en.
Перевірка через notFound() важлива. Без неї значення на кшталт /de могло б потрапити в сторінку як невідома локаль.
Атрибут lang на елементі <html> допомагає браузерам, засобам доступності та пошуковим системам визначати мову сторінки.
app/[locale]/page.tsximport { notFound } from "next/navigation";
import { getDictionary } from "../../i18n/dictionaries";
import { isLocale } from "../../i18n/config";
type HomePageProps = {
params: Promise<{
locale: string;
}>;
};
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params;
if (!isLocale(locale)) {
notFound();
}
const dictionary = getDictionary(locale);
return (
<>
<h1>{dictionary.homeTitle}</h1>
<p>{dictionary.homeDescription}</p>
</>
);
}app/[locale]/about/page.tsximport { notFound } from "next/navigation";
import { getDictionary } from "../../../i18n/dictionaries";
import { isLocale } from "../../../i18n/config";
type AboutPageProps = {
params: Promise<{
locale: string;
}>;
};
export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = await params;
if (!isLocale(locale)) {
notFound();
}
const dictionary = getDictionary(locale);
return (
<>
<h1>{dictionary.aboutTitle}</h1>
<p>{dictionary.aboutDescription}</p>
</>
);
}Тепер Next.js обробляє такі адреси:
/uk → українська головна сторінка
/en → англійська головна сторінка
/uk/about → українська сторінка «Про нас»
/en/about → англійська сторінка «Про нас»Якщо користувач відкриє /, потрібно перенаправити його на локалізовану адресу. Також зручно додавати префікс до маршрутів без локалі, наприклад перетворювати /about на /uk/about.
Для цього використаємо Middleware.
middleware.tsimport { NextRequest, NextResponse } from "next/server";
import { defaultLocale, isLocale } from "./i18n/config";
const publicFile = /\.[^/]+$/;
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api") ||
publicFile.test(pathname)
) {
return NextResponse.next();
}
const firstSegment = pathname.split("/")[1];
if (isLocale(firstSegment)) {
return NextResponse.next();
}
const url = request.nextUrl.clone();
url.pathname =
pathname === "/" ? `/${defaultLocale}` : `/${defaultLocale}${pathname}`;
return NextResponse.redirect(url);
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};Тепер результат буде таким:
/ → /uk
/about → /uk/about
/en → /en
/en/about → /en/aboutЯкщо URL уже починається з підтримуваної локалі, Middleware не змінює запит.
Перемикач мови має змінювати тільки перший сегмент URL, залишаючи поточний маршрут.
Наприклад:
/uk/about → /en/aboutcomponents/language-switcher.tsx"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { locales, type Locale } from "../i18n/config";
type LanguageSwitcherProps = {
currentLocale: Locale;
label: string;
};
function getLocalizedPathname(
pathname: string,
targetLocale: Locale,
): string {
const segments = pathname.split("/");
const firstSegment = segments[1];
if (locales.includes(firstSegment as Locale)) {
segments[1] = targetLocale;
} else {
segments.splice(1, 0, targetLocale);
}
const localizedPathname = segments.join("/");
if (localizedPathname.length > 1 && localizedPathname.endsWith("/")) {
return localizedPathname.slice(0, -1);
}
return localizedPathname || `/${targetLocale}`;
}
export function LanguageSwitcher({
currentLocale,
label,
}: LanguageSwitcherProps) {
const pathname = usePathname() || `/${currentLocale}`;
return (
<div>
<span>{label}: </span>
{locales.map((locale) => {
const href = getLocalizedPathname(pathname, locale);
return (
<span key={locale}>
{locale === currentLocale ? (
<strong>{locale}</strong>
) : (
<Link href={href}>{locale}</Link>
)}
{" "}
</span>
);
})}
</div>
);
}Компонент є клієнтським, тому що usePathname доступний лише в Client Components.
Для сторінки /uk/about він побудує такі адреси:
uk → /uk/about
en → /en/aboutДля головної сторінки /uk результатом буде /en.
У кореневому layout не потрібно дублювати локалізований інтерфейс. Він може містити лише глобальні стилі або метадані.
app/layout.tsximport type { Metadata } from "next";
export const metadata: Metadata = {
title: "Multilingual application",
description: "Application with localized routes",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return children;
}Оскільки елемент <html> створюється в app/[locale]/layout.tsx, кореневий layout просто повертає дочірній вміст.
Для запиту до /en/about послідовність така:
Middleware перевіряє перший сегмент URL.
Значення en знаходиться серед доступних локалей.
Запит передається маршруту app/[locale]/about/page.tsx.
Сегмент [locale] отримує значення "en".
Сторінка завантажує англійський словник.
Layout встановлює <html lang="en">.
Користувач отримує англійську версію сторінки.
Для запиту до /about Middleware додасть локаль за замовчуванням:
/about → /uk/aboutЗберігайте список локалей в одному модулі.
Перевіряйте значення locale до використання словника.
Використовуйте однакову структуру маршрутів для всіх мов.
Не дублюйте сторінки для кожної локалі вручну.
Змінюйте локаль у URL, а не лише текст інтерфейсу.
Для кожної сторінки встановлюйте правильний атрибут lang.
У перемикачі зберігайте поточний шлях після заміни локалі.
Неправильна структура:
app/
├── uk/
│ └── page.tsx
└── en/
└── page.tsxВона працює для двох сторінок, але швидко призводить до дублювання коду.
Краще використовувати:
app/
└── [locale]/
└── page.tsxНе варто без перевірки передавати параметр URL у словник:
const dictionary = dictionaries[locale];Користувач може відкрити /unknown, а словник для такої локалі не існує. Перевіряйте значення через isLocale і викликайте notFound().
Недостатньо зробити посилання:
<Link href="/en">English</Link>Якщо користувач перебуває на /uk/about, перемикання має вести на /en/about, а не втрачати поточну сторінку.
Такий підхід ускладнює підтримку:
<h1>{locale === "uk" ? "Головна сторінка" : "Home page"}</h1>Краще зберігати тексти у словниках і отримувати їх за локаллю.
Middleware не повинен додавати локаль до шляхів на кшталт:
/_next/static/...
/_next/image/...
/favicon.icoТому такі шляхи потрібно виключити з обробки.
Локаль можна зробити динамічним сегментом [locale].
Префікс локалі створює окремі URL для різних мов.
Middleware додає локаль до маршрутів без префікса.
generateStaticParams визначає підтримувані локалізовані маршрути.
Словники зберігають тексти окремо від компонентів.
Перемикач мови замінює перший сегмент URL і зберігає поточний шлях.
Невідомі локалі потрібно обробляти через notFound().