Перевод интерфейса меняет текст, но сам по себе не создаёт отдельную языковую страницу. Один адрес /search/music может показать английский или русский интерфейс в зависимости от client state, cookie или настроек браузера. URL при этом остаётся тем же.

Для поисковой индексации языковые версии удобнее разделить адресами:

/en/search/music
/ru/search/music

Каждая версия получает свой HTML, внутренние ссылки, metadata и canonical. hreflang связывает две страницы как языковые варианты одного материала.

Google рекомендует использовать разные URL для разных языковых версий вместо одной страницы, которая меняет язык по cookie или настройкам браузера. Для отдельных URL Google рекомендует указывать hreflang. Автоматическая подмена содержимого по Accept-Language может оставить часть вариантов вне обхода, поскольку Googlebot не обязан отправлять этот заголовок.

В проекте EventMap используются две локали - en и ru. Дальше проект упоминается только как реализация общей схемы.

Locale в URL

App Router строит маршруты из структуры каталогов. Языковой сегмент можно поставить первым dynamic segment:

app/
  [locale]/
    page.tsx
    search/
      [[...segments]]/
        page.tsx
    events/
      [id]/
        page.tsx

Один набор страниц обслуживает обе версии:

/en
/ru

/en/search/music
/ru/search/music

/en/events/12345
/ru/events/12345

next-intl поддерживает locale-aware routing и отдельные URL для языковых версий. Библиотека работает с App Router, Server Components и локализованной навигацией.

В проекте список локалей хранится в routing config:

import { defineRouting } from "next-intl/routing";

export const routing = defineRouting({
  locales: ["en", "ru"],
  defaultLocale: "en",
  pathnames: {
    "/": "/",
    "/demo": "/demo",
    "/search": "/search",
    "/favorites": "/favorites",
  },
});

en и ru становятся допустимыми значениями первого сегмента URL.

Маршруты приложения собираются отдельными функциями:

export const routes = {
  home: (locale: Locale) => `/${locale}`,
  login: (locale: Locale) => `/${locale}/login`,
  search: (locale: Locale) => `/${locale}/search`,
  favorites: (locale: Locale) => `/${locale}/favorites`,
  eventDetail: (locale: Locale, id: string) =>
    `/${locale}/events/${id}`,
} as const;

Каждая внутренняя ссылка получает locale явно.

Проверка [locale]

Dynamic segment приходит из URL и остаётся внешним вводом. Кроме /en/search пользователь может запросить /de/search, /abc/search или любой другой сегмент. Список допустимых значений используется как runtime-проверка:

import { routing } from "@/i18n/routing";

export const locales = routing.locales;

export type Locale = (typeof locales)[number];

export const defaultLocale = routing.defaultLocale;

export function isLocale(value: string): value is Locale {
  return locales.includes(value as Locale);
}

Страница проверяет params.locale до работы с переводами и данными:

export async function getLocaleFromParams(
  params: LocaleParams,
): Promise<Locale> {
  const { locale } = await params;

  if (!isLocale(locale)) {
    notFound();
  }

  return locale;
}

Неизвестная locale заканчивается 404, а не загрузкой default language под случайным URL.

Для статических языковых маршрутов layout также возвращает известные значения через generateStaticParams:

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

Сборщик получает /en и /ru из того же списка, который используется при runtime-проверке.

Locale и HTML

Язык в URL должен совпадать с языком страницы. В layout locale попадает в <html lang>:

export default async function LocaleLayout({
  children,
  modal,
  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>

          {modal}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

URL /ru/... получает lang="ru", /en/... получает lang="en".

Google определяет язык страницы по видимому содержимому и не использует hreflang или HTML lang как основной механизм определения языка. Перевод только navigation и footer при неизменном основном содержимом может оставить две версии практически одинаковыми. Поэтому locale относится не только к надписям кнопок. Заголовок, основной текст, результаты поиска, подписи карточек и metadata должны соответствовать выбранной версии.

Messages и server translations

next-intl загружает messages по текущей locale. Request config проверяет значение и выбирает соответствующий JSON:

export default getRequestConfig(
  async ({ requestLocale }) => {
    const requested = await requestLocale;

    const locale =
      requested && isLocale(requested)
        ? requested
        : defaultLocale;

    return {
      locale,
      messages:
        (await import(`../../messages/${locale}.json`))
          .default,
    };
  },
);

messages/en.json и messages/ru.json содержат UI-тексты для двух версий.

Server Component получает переводы без client-side переключения:

export async function Nav({ locale }: NavProps) {
  const t = await getTranslations({
    locale,
    namespace: "nav",
  });

  const navItems = [
    {
      href: routes.home(locale),
      label: t("home"),
    },
    {
      href: routes.search(locale),
      label: t("search"),
    },
    {
      href: routes.favorites(locale),
      label: t("favorites"),
    },
  ];

  // ...
}

getTranslations работает в async Server Components и может использоваться в server-side функциях, включая generateMetadata.

Страница получает locale до запроса переводов:

const locale = await getLocaleFromParams(params);

const t = await getTranslations({
  locale,
  namespace: "search",
});

Тот же locale передаётся дальше в серверный слой данных.

Locale в данных

UI messages и содержимое страницы относятся к разным источникам.

messages/ru.json может перевести кнопку Search в Поиск, но событие с английским title и description останется английским. URL /ru/events/12345 при этом содержит русский locale, хотя основное содержимое detail page осталось на другом языке.

Серверный запрос получает locale явно:

const event = await fetchEventById({
  id,
  locale,
});

Search работает по той же схеме:

const searchResult =
  await getSearchPageEvents({
    filters,
    locale,
  });

Серверный слой знает язык до получения или нормализации данных.

Для внешнего provider это может означать language parameter, отдельный localized source или собственный слой переводов. Для controlled content locale можно хранить рядом с title, description и другими текстовыми полями.

Locale во внутренних ссылках

Языковая версия теряется, если часть ссылок строится без locale. Пользователь находится здесь:

/ru/search/music

Ссылка вида:

/events/12345

выводит navigation за пределы locale tree. Ссылка с текущим locale остаётся внутри русской версии:

/ru/events/12345

В проекте navigation строит URL через общий routes:

const navItems = [
  {
    href: routes.home(locale),
    label: t("home"),
  },
  {
    href: routes.search(locale),
    label: t("search"),
  },
  {
    href: routes.favorites(locale),
    label: t("favorites"),
  },
  {
    href: routes.login(locale),
    label: t("login"),
  },
];

Форма поиска тоже сохраняет язык:

<form
  action={`/${locale}/search`}
  method="get"
>
  <input name="q" type="search" />
  <button type="submit">
    {t("searchButton")}
  </button>
</form>

Запрос с русской главной уходит на /ru/search, английский на /en/search.

Переключение языка

Language switcher меняет первый сегмент URL и сохраняет остальные части маршрута.

Для страницы:

/en/search/music?q=jazz&page=2

переключение на русский должно вести сюда:

/ru/search/music?q=jazz&page=2

Категория, строка запроса и пагинация остаются теми же.

В проекте switcher читает pathname и query string:

function getLocalizedHref(
  pathname: string,
  targetLocale: Locale,
  query: string,
) {
  const segments =
    pathname.split("/").filter(Boolean);

  const pathSegments =
    isLocale(segments[0] ?? "")
      ? segments.slice(1)
      : segments;

  const localizedPath =
    `/${[
      targetLocale,
      ...pathSegments,
    ].join("/")}`;

  return query
    ? `${localizedPath}?${query}`
    : localizedPath;
}

Компонент получает текущий pathname и searchParams:

export function LanguageSwitcher({
  locale,
}: LanguageSwitcherProps) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const query = searchParams.toString();

  return (
    <div aria-label="Language switcher">
      {locales.map((targetLocale) => (
        <a
          aria-current={
            targetLocale === locale
              ? "page"
              : undefined
          }
          href={getLocalizedHref(
            pathname,
            targetLocale,
            query,
          )}
          key={targetLocale}
        >
          {targetLocale.toUpperCase()}
        </a>
      ))}
    </div>
  );
}

Metadata по locale

Разные языковые URL получают отдельный title и description. Next.js App Router поддерживает статический объект metadata и async generateMetadata в page или layout. Framework формирует соответствующие элементы <head> из возвращённого объекта.

На главной странице locale читается до сборки metadata:

export async function generateMetadata({
  params,
}: PageProps): Promise<Metadata> {
  const locale =
    await getLocaleFromParams(params);

  const seoPaths =
    buildHomeSeoPaths(locale);

  return toNextMetadata({
    title:
      locale === "ru"
        ? "Живые события рядом"
        : "Discover Live Events",

    description:
      locale === "ru"
        ? "Ищите концерты, спорт, театр, кино и живые события рядом."
        : "Find concerts, sports, theatre, film and live events near you.",

    ...seoPaths,
  });
}

/ru и /en получают разные title и description из одного generateMetadata.

Search page использует locale вместе с фильтрами:

const meta = buildSearchMeta({
  currentPage: Math.max(filters.page, 1),
  filters,
  locale,
  totalCount: 1,
  totalPages: 1,
});

Комбинации /en/search/music и /ru/search/music получают metadata для одной категории на разных языках.

Canonical для языковых страниц

Canonical относится к конкретной языковой версии

Для английской страницы:

URL
/en/search/music

canonical
/en/search/music

Для русской:

URL
/ru/search/music

canonical
/ru/search/music

Русский URL не получает canonical на английский только потому, что страницы описывают одну категорию. При переведённом основном содержимом это две языковые версии.

Google рассматривает разные языковые страницы как дубли только когда основной контент фактически остаётся на одном языке. Для региональных вариантов с очень похожим содержимым Google отдельно рекомендует использовать canonical вместе с hreflang.

В проекте canonical строится из текущей locale:

export function buildHomeSeoPaths(
  locale: Locale,
): SeoPaths {
  return {
    canonicalPath: routes.home(locale),

    languageAlternates:
      buildLocalizedAlternates(
        routes.home,
      ),
  };
}

canonicalPath указывает на текущую языковую страницу.

Hreflang между версиями

hreflang связывает соответствующие языковые URL. Для /en/search/music набор выглядит так:

<link
  rel="alternate"
  hreflang="en"
  href="https://example.com/en/search/music"
/>

<link
  rel="alternate"
  hreflang="ru"
  href="https://example.com/ru/search/music"
/>

Русская страница содержит такой же набор ссылок, включая ссылку на саму себя.

Google требует, чтобы каждая языковая версия перечисляла себя и остальные варианты. Связи должны быть взаимными. Для HTML-аннотаций используются полностью квалифицированные URL.

Проект собирает alternates из одного списка locales:

function buildLocalizedAlternates(
  getPath: (locale: Locale) => string,
): Record<Locale, string> {
  return Object.fromEntries(
    locales.map((locale) => [
      locale,
      getPath(locale),
    ]),
  ) as Record<Locale, string>;
}

Добавление locale в общий список автоматически добавляет её в набор language alternates для всех helper, которые используют эту функцию.

Search page передаёт те же фильтры обеим версиям:

export function buildSearchSeoPaths({
  filters,
  locale,
}: {
  filters: SearchFilters;
  locale: Locale;
}): SeoPaths {
  const getPath =
    (targetLocale: Locale) =>
      buildSearchHref({
        filters,
        locale: targetLocale,
      });

  return {
    canonicalPath: getPath(locale),

    languageAlternates:
      buildLocalizedAlternates(getPath),
  };
}

Для music + london helper связывает /en/search/music/london с /ru/search/music/london.

Detail page использует один id:

export function buildEventSeoPaths({
  id,
  locale,
}: {
  id: string;
  locale: Locale;
}): SeoPaths {
  const getPath =
    (targetLocale: Locale) =>
      routes.eventDetail(
        targetLocale,
        id,
      );

  return {
    canonicalPath: getPath(locale),

    languageAlternates:
      buildLocalizedAlternates(getPath),
  };
}

Один event ID связывает две языковые detail pages.

Metadata API и hreflang

Next.js получает canonical и language alternates через поле alternates Metadata API. В проекте относительные пути сначала переводятся в абсолютные URL:

const languages =
  languageAlternates
    ? Object.fromEntries(
        Object.entries(
          languageAlternates,
        ).flatMap(
          ([locale, path]) => {
            const href =
              toAbsoluteUrl(path);

            return href
              ? [[locale, href]]
              : [];
          },
        ),
      )
    : undefined;

Потом metadata получает canonical и languages:

return {
  title,
  description,

  alternates:
    canonical || languages
      ? {
          canonical,
          languages,
        }
      : undefined,

  openGraph: {
    title,
    description,
    images,
    siteName: siteConfig.name,
    type: "website",
  },

  twitter: {
    card: "summary_large_image",
    title,
    description,
    images:
      absoluteImageUrl
        ? [absoluteImageUrl]
        : undefined,
  },
};

Next.js формирует соответствующие <link rel="canonical"> и language alternate элементы в <head>.

Canonical и hreflang получают данные из одного SeoPaths, а не собираются независимо в каждой странице.

Sitemap и локали

Sitemap перечисляет обе языковые версии страниц. Для главной получаются два URL:

/en
/ru

Для категорий:

/en/search/music
/ru/search/music

/en/search/sports
/ru/search/sports

Для controlled detail pages:

/en/events/12345
/ru/events/12345

Builder проходит по locales:

export function buildStaticSitemapEntries():
  MetadataRoute.Sitemap {
  return locales.flatMap(
    (locale) => [
      createSitemapEntry({
        path: routes.home(locale),
        changeFrequency: "daily",
        priority: 1,
      }),
    ],
  );
}

Search entries строятся по тому же списку:

export function buildSearchSitemapEntries():
  MetadataRoute.Sitemap {
  return locales.flatMap(
    (locale) => [
      createSitemapEntry({
        path: routes.search(locale),
        changeFrequency: "daily",
        priority: 0.9,
      }),

      ...searchCategories.map(
        (category) =>
          createSitemapEntry({
            path:
              `${routes.search(locale)}` +
              `/${category.slug}`,
            changeFrequency: "daily",
            priority: 0.8,
          }),
      ),
    ],
  );
}

Dynamic detail entries получают locale вместе с ID:

export function getStaticEventParams():
  StaticEventParams[] {
  const eventIds =
    getControlledEventIds();

  return locales.flatMap(
    (locale) =>
      eventIds.map((id) => ({
        locale,
        id,
      })),
  );
}

Каждый controlled event попадает в sitemap для en и ru.

app/sitemap.ts отдаёт общий список:

import type {
  MetadataRoute,
} from "next";

import {
  buildSitemapEntries,
} from "@/entities/seo/sitemapEntries";

export default function sitemap():
  MetadataRoute.Sitemap {
  return buildSitemapEntries();
}

Google поддерживает hreflang через HTML, HTTP headers или sitemap и считает эти способы эквивалентными. Проект хранит hreflang в HTML metadata, а sitemap перечисляет сами локализованные URL.

Locale после callback и redirect

Не все служебные маршруты живут внутри [locale]. OAuth callback может выглядеть так:

/auth/callback

После авторизации серверу нужно вернуть пользователя на исходную языковую страницу:

/ru/favorites

Если callback знает только /favorites, locale теряется и пользователь возвращается в default language.

В проекте исходный адрес передаётся через next:

/auth/callback?next=/ru/favorites

Функция проверяет, что redirect остаётся внутренним:

export function getSafeAuthNext(
  value: string | null,
  fallback: string,
): string {
  if (!value) {
    return fallback;
  }

  if (!value.startsWith("/")) {
    return fallback;
  }

  if (value.startsWith("//")) {
    return fallback;
  }

  try {
    const parsed = new URL(
      value,
      "https://eventmap.local",
    );

    if (
      parsed.origin !==
      "https://eventmap.local"
    ) {
      return fallback;
    }

    return (
      `${parsed.pathname}` +
      `${parsed.search}` +
      `${parsed.hash}`
    );
  } catch {
    return fallback;
  }
}

В next остаётся полный локализованный pathname.

Callback определяет locale для страницы ошибки из сохранённого пути:

function getLocaleFromNextPath(
  path: string,
): Locale {
  return (
    path === "/ru" ||
    path.startsWith("/ru/")
  )
    ? "ru"
    : "en";
}

Успешный callback возвращает пользователя на safeNext:

return NextResponse.redirect(
  new URL(
    safeNext,
    request.url,
  ),
);

Locale переживает переход через служебный route вне [locale].

Связи i18n в App Router

Маршрут /ru/search/music?q=jazz содержит locale, search category и query string. Сервер проверяет ru, загружает русские messages и передаёт locale в данные. Внутренние ссылки продолжаются с /ru. Language switcher меняет первый сегмент на /en и сохраняет остальной URL.

generateMetadata использует тот же locale и те же фильтры. Русская страница получает self-canonical /ru/search/music?q=jazz. Language alternates содержат русскую и английскую версии этого же состояния. Sitemap строит известные URL отдельно для en и ru.

Auth callback хранит локализованный next и возвращает пользователя в ту же языковую ветку после внешнего перехода.

В этой схеме locale проходит через URL, Server Components, данные, links, metadata, canonical, hreflang, sitemap и redirects одним значением.