Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи? Написать
Войти
Дайджесты новостей
Архитектура мультиязычной маршрутизации и SEO-индексации в Next.js

Интернационализация в Next.js App Router: архитектура маршрутизации [locale], hreflang и предотвращение SEO-дублей

Неправильная настройка интернационализации в Next.js App Router провоцирует масштабные SEO-дубли и падение поискового трафика при динамической смене языка интерфейса. Карточка разбирает маршрутизацию [locale], правила генерации метаданных, атрибутов hreflang, канонических ссылок и мультиязычных карт сайта.

Интернационализация в 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:

  1. /en/item/1 ссылается на /ru/item/1 через hreflang="ru", а /ru/item/1 на /en/item/1 через hreflang="en".
  2. Каждая страница содержит self-referencing ссылку на саму себя.
  3. Ссылки формируются в виде абсолютных 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 StateCookie RedirectRouting 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-шаговый чеклист перед релизом

  1. Изоляция URL: все публичные страницы находятся внутри app/[locale]/, корень / перенаправляет на дефолтный язык.
  2. Строгая валидация ввода: функция getLocaleFromParams вызывает notFound() при невалидном сегменте.
  3. Разметка документа: атрибут <html lang="..."> корневого тега синхронизирован с параметром запроса.
  4. Сквозная локализация данных: сущности из базы данных запрашиваются с явной передачей текущей локали.
  5. Симметричный hreflang и canonical: для каждого адреса сгенерирован self-canonical и взаимные ссылки hreflang.
  6. Мультиязычный sitemap.xml: карта сайта содержит URL для всех языков, а сервисные пути закрыты от обхода.