Интернационализация в Next.js App Router: архитектура маршрутизации locale, hreflang и предотвращение SEO-дублей
Реализация мультиязычности нередко сводится к поверхностной смене языка на клиенте через LocalStorage или cookie. Для пользователя интерфейс обновляется быстро, однако для поисковых систем такая схема губительна. Роботы Googlebot и Яндекс не сохраняют cookie и не обязаны передавать заголовок Accept-Language при обходе страниц. Когда по одному адресу /catalog отдается то русский, то английский контент, краулер фиксирует нестабильность содержимого, и страницы выпадают из выдачи из-за взаимной каннибализации и дублирования.
Надежным фундаментом для SEO является изоляция каждой языковой версии под собственным префиксом URL. В Next.js App Router это реализуется через динамический сегмент [locale], строгую серверную валидацию и сквозную синхронизацию метаданных.
Маршрутизация на базе locale и библиотека next-intl
В App Router маршруты строятся по файловой структуре. Публичные страницы помещаются в подпапку app/[locale]/. В итоге один набор компонентов обслуживает параллельные пространства: /en/catalog, /ru/catalog, /en/events/10 и /ru/events/10.
Библиотека next-intl обеспечивает декларативную настройку допустимых языков через defineRouting:
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "ru"],
defaultLocale: "en",
pathnames: {
"/": "/",
"/search": "/search",
"/catalog": "/catalog",
},
});
Для исключения битых ссылок адреса формируются централизованно:
export const routes = {
home: (locale: Locale) => `/${locale}`,
catalog: (locale: Locale) => `/${locale}/catalog`,
item: (locale: Locale, id: string) => `/${locale}/catalog/${id}`,
} as const;
Явное указание локали удерживает пользователя и краулер внутри выбранной языковой ветки без случайных переходов.
Валидация параметров: почему откат к дефолту опаснее ошибки 404
Сегмент [locale] — внешний ввод. При запросе несуществующей локали (например, /fr/catalog) сервер не должен незаметно откатываться к defaultLocale с кодом 200. Если английский текст отдается по произвольным адресам, поисковики проиндексируют тысячи мусорных дублей.
Корректный подход требует строгой проверки локали до обращения к словарям или базе данных:
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
export const locales = routing.locales;
export type Locale = (typeof locales)[number];
export function isLocale(val: string): val is Locale {
return locales.includes(val as Locale);
}
export async function getLocaleFromParams(params: Promise<{ locale: string }>): Promise<Locale> {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return locale;
}
Вызов notFound() возвращает 404. Для статической генерации generateStaticParams возвращает строгий массив локалей:
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
Атрибут и локализация данных
В LocaleLayout параметр локали попадает в тег разметки <html lang={locale}>:
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const locale = await getLocaleFromParams(params);
const messages = (await import(`../../../messages/${locale}.json`)).default;
return (
<html lang={locale}>
<body>
<NextIntlClientProvider locale={locale} messages={messages}>
<PageShell locale={locale}>{children}</PageShell>
</NextIntlClientProvider>
</body>
</html>
);
}
Ограничиться переводом кнопок недостаточно. Если интерфейс на русском, а карточка товара на английском, краулер сочтет страницу некачественным дублем. React Server Components обязаны передавать локаль в слой данных:
const t = await getTranslations({ locale, namespace: "catalog" });
const product = await fetchProductById({ id, locale });
Только при согласованности доменных сущностей и UI страница формирует цельный поисковый сигнал.
Language Switcher и сохранение параметров запроса
Переключатель языков обязан сохранять путь и параметры фильтрации. При переходе с /en/catalog?sort=price&page=2 на русский язык адрес должен трансформироваться в /ru/catalog?sort=price&page=2:
export function getLocalizedHref(pathname: string, targetLocale: Locale, query?: Record<string, string>): string {
const segments = pathname.split("/").filter(Boolean);
if (segments.length > 0 && isLocale(segments[0])) {
segments[0] = targetLocale;
} else {
segments.unshift(targetLocale);
}
const search = query && Object.keys(query).length > 0 ? "?" + new URLSearchParams(query).toString() : "";
return "/" + segments.join("/") + search;
}
Ссылки помечаются aria-current="page" для текущей активной локали.
Метаданные, канонические ссылки и hreflang
Главное правило SEO: каждая языковая версия указывает каноническим адресом на саму себя. Назначение английской версии канонической для русской страницы исключает русскую страницу из поискового индекса.
Связывание версий выполняется строго через двунаправленный hreflang:
/en/item/1ссылается на/ru/item/1черезhreflang="ru", а/ru/item/1на/en/item/1черезhreflang="en".- Каждая страница содержит self-referencing ссылку на саму себя.
- Ссылки формируются в виде абсолютных URL.
В Next.js это конфигурируется через generateMetadata:
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const locale = await getLocaleFromParams(params);
const baseUrl = "https://example.com";
return {
title: locale === "ru" ? "Каталог товаров" : "Product Catalog",
alternates: {
canonical: `${baseUrl}/${locale}/catalog`,
languages: {
en: `${baseUrl}/en/catalog`,
ru: `${baseUrl}/ru/catalog`,
},
},
};
}
Мультиязычный sitemap.ts и сервисные редиректы
Файл app/sitemap.ts генерирует карту для всех поддерживаемых языков:
import { MetadataRoute } from "next";
import { routing } from "@/i18n/routing";
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = "https://example.com";
return routing.locales.flatMap((locale) => [
{ url: `${baseUrl}/${locale}`, lastModified: new Date(), priority: 1.0 },
{ url: `${baseUrl}/${locale}/catalog`, lastModified: new Date(), priority: 0.8 },
]);
}
Для сервисных маршрутов (например, /auth/callback?next=/ru/favorites) необходима защита от Open Redirect:
export function getSafeAuthNext(value: string | null, fallback: string = "/en"): string {
if (!value || !value.startsWith("/") || value.startsWith("//")) return fallback;
try {
const parsed = new URL(value, "https://example.com");
return parsed.origin === "https://example.com" ? `${parsed.pathname}${parsed.search}` : fallback;
} catch {
return fallback;
}
}
Сравнение стратегий интернационализации
| Критерий | Client State | Cookie Redirect | Routing locale |
|---|---|---|---|
| Индексация роботами | Невозможна | Нестабильна | Полная по всем языкам |
| SEO-дублирование | Высокий риск | Риск каннибализации | Исключено с self-canonical |
| Поддержка hreflang | Нет | Сложные HTTP-заголовки | Нативная в Metadata API |
| Кеширование на CDN | Один общий HTML | Раздувание Vary | Эффективное по URL |
| Шеринг ссылок | Дефолт получателя | Зависит от региона | Точный целевой язык |
Схема обработки мультиязычного запроса
[Краулер или браузер] ──► [GET /ru/catalog/42]
│
[Валидация locale] ──(ошибка)──► [notFound() -> 404]
│ (успех)
[LocaleLayout: <html lang="ru">]
│
┌────────────────┴────────────────┐
▼ ▼
[generateMetadata] [Server Components]
- self-canonical: /ru/... - getTranslations("ru")
- alternates: en, ru - fetchProductById({ id: 42, locale: "ru" })
│ │
└────────────────┬────────────────┘
▼
[Серверный HTML с метатегами]
6-шаговый чеклист перед релизом
- Изоляция URL: все публичные страницы находятся внутри
app/[locale]/, корень/перенаправляет на дефолтный язык. - Строгая валидация ввода: функция
getLocaleFromParamsвызываетnotFound()при невалидном сегменте. - Разметка документа: атрибут
<html lang="...">корневого тега синхронизирован с параметром запроса. - Сквозная локализация данных: сущности из базы данных запрашиваются с явной передачей текущей локали.
- Симметричный hreflang и canonical: для каждого адреса сгенерирован self-canonical и взаимные ссылки
hreflang. - Мультиязычный sitemap.xml: карта сайта содержит URL для всех языков, а сервисные пути закрыты от обхода.
