scrpt .ru
· 8 min
Схема прохождения значения locale через маршрут, серверные компоненты, данные, ссылки и metadata в Next.js App Router

Где проходит граница между переводом и языковой версией

Переключатель языка, который меняет тексты кнопок по cookie или client state, оставляет один и тот же адрес. Страница /search/music при этом показывает то английский, то русский интерфейс, а URL не меняется. Для поисковой индексации этого мало: Google рекомендует отдавать языковые версии по разным адресам вида /en/search/music и /ru/search/music, а не по одной странице, которая подменяет язык по настройкам браузера. Автоматическая подмена по заголовку Accept-Language вдобавок рискует оставить часть вариантов вне обхода — Googlebot не обязан этот заголовок отправлять.

Разбор реализации на Next.js App Router (пример из проекта EventMap с локалями en и ru) показывает главное: языковая версия существует, когда у неё свой HTML, свои внутренние ссылки, своя metadata и canonical, а hreflang связывает варианты между собой. Технически всё это держится на одном значении locale, которое проходит через URL, серверные компоненты, слой данных, ссылки, metadata, canonical, hreflang, sitemap и служебные редиректы. Там, где путь собирается без этого значения, языковая версия молча теряется.

Locale в первом сегменте маршрута

App Router строит маршруты из структуры каталогов, поэтому языковой сегмент ставят первым динамическим сегментом: app/[locale]/page.tsx, дальше внутри — search/[[...segments]] и events/[id]. Один набор страниц обслуживает обе версии: /en/search/music и /ru/search/music, /en/events/12345 и /ru/events/12345.

Список локалей задаётся один раз в конфиге маршрутизации. С next-intl это defineRouting({ locales: ["en", "ru"], defaultLocale: "en", ... }) — после этого en и ru становятся допустимыми значениями первого сегмента. Тот же список отдаётся сборщику через generateStaticParams, который возвращает locales.map((locale) => ({ locale })). Сборка получает /en и /ru из того же массива, что используется в рантайме.

Сегмент из URL — это внешний ввод

Динамический сегмент приходит из адресной строки, поэтому кроме /en/search пользователь может запросить /de/search, /abc/search или что угодно ещё. Список допустимых локалей работает как рантайм-проверка: функция isLocale(value) проверяет вхождение в routing.locales, а getLocaleFromParams вызывает notFound(), если значение неизвестно. Неизвестная locale заканчивается ответом 404, а не загрузкой языка по умолчанию под случайным адресом. Проверка выполняется до обращения к переводам и данным.

Язык URL должен совпадать с языком страницы

В layout значение locale попадает в <html lang={locale}>: /ru/... получает lang="ru", /en/... — lang="en". Но сам по себе атрибут ничего не решает: Google определяет язык страницы по видимому содержимому и не использует hreflang или html lang как основной механизм. Если перевести только навигацию и подвал, а основной текст оставить как есть, две версии окажутся почти одинаковыми — и тогда они действительно выглядят как дубли.

Поэтому locale относится не только к надписям в интерфейсе. Заголовок, основной текст, результаты поиска, подписи карточек и metadata должны соответствовать версии. next-intl грузит messages/en.json или messages/ru.json по текущей локали, а getTranslations({ locale, namespace }) работает в асинхронных серверных компонентах, включая generateMetadata, — переводы приходят без клиентского переключения.

Locale доходит до слоя данных

UI-тексты и содержимое страницы берутся из разных источников. messages/ru.json переведёт кнопку «Поиск», но событие с английскими title и description останется английским, даже если адрес — /ru/events/12345. Чтобы этого избежать, серверный запрос получает язык явно: fetchEventById({ id, locale }), getSearchPageEvents({ filters, locale }). Серверный слой знает язык до получения и нормализации данных. Для внешнего провайдера это может быть параметр языка или отдельный локализованный источник; для собственного контента locale хранят рядом с текстовыми полями.

Внутренние ссылки и переключатель языка

Языковая версия рвётся там, где часть ссылок строится без сегмента. Пользователь на /ru/search/music, ссылка /events/12345 выводит его за пределы русской ветки, а /ru/events/12345 оставляет внутри. Поэтому навигация собирает адреса через общий модуль маршрутов, где каждая функция принимает locale: routes.eventDetail(locale, id), routes.search(locale). Форма поиска тоже сохраняет язык: action={/${locale}/search} — запрос с русской главной уходит на /ru/search.

Переключатель языка меняет только первый сегмент и сохраняет остальной маршрут. Для /en/search/music?q=jazz&page=2 переход на русский ведёт на /ru/search/music?q=jazz&page=2: категория, строка запроса и пагинация те же. Функция getLocalizedHref разбивает pathname на сегменты, отбрасывает первый, если это локаль, и склеивает целевую локаль с остатком, дописывая query string.

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

Canonical указывает на конкретную языковую версию: английская страница /en/search/music каноникализируется на себя, русская /ru/search/music — на себя. Русский адрес не получает canonical на английский только потому, что страницы описывают одну категорию. Google считает разные языковые страницы дублями лишь тогда, когда основной контент фактически остаётся на одном языке; для региональных вариантов с очень похожим содержимым он отдельно советует сочетать canonical с hreflang.

hreflang связывает соответствующие адреса, и связь должна быть взаимной: каждая версия перечисляет и себя, и остальные варианты полностью квалифицированными URL. В разборе оба набора — canonical и alternates — собираются из одного объекта SeoPaths, а не независимо на каждой странице. Добавление новой локали в общий список locales автоматически расширяет набор alternates во всех хелперах, которые проходят по этому списку. Sitemap перечисляет обе версии каждой страницы; Google поддерживает hreflang через HTML, HTTP-заголовки или sitemap и считает эти способы равнозначными.

Locale за пределами сегмента [locale]

Не все служебные маршруты живут внутри [locale]. OAuth-callback вида /auth/callback лежит снаружи, и после авторизации сервер должен вернуть пользователя на исходную языковую страницу. Если callback знает только /favorites, язык теряется. В разборе исходный адрес передаётся через параметр: /auth/callback?next=/ru/favorites.

Функция getSafeAuthNext проверяет, что редирект остаётся внутренним: значение должно начинаться с одного /, не начинаться с //, а после разбора через new URL(value, base) его origin должен совпадать с базовым. Для страницы ошибки локаль вычисляется из сохранённого пути: /ru или префикс /ru/ дают ru, всё остальное — en. Так locale переживает переход через маршрут вне [locale].

Что проверять

У всей схемы одна точка отказа: locale исчезает везде, где адрес или запрос собирается без него. Практический список мест, где это происходит незаметно, короткий. Внутренняя ссылка, построенная строкой /events/${id} вместо хелпера с локалью. Запрос к данным без параметра языка — интерфейс переведён, содержимое нет. Служебный редирект, который знает путь без сегмента локали. Metadata, собранная до чтения params.locale. Sitemap или набор hreflang, куда новую локаль забыли внести, потому что список ведётся в двух местах.

Обратный признак исправной реализации — locale существует ровно в одном источнике (список locales в конфиге маршрутизации) и оттуда расходится по всем слоям. Если для проверки языковой версии приходится открывать больше одного файла со списком локалей, набор alternates и canonical рано или поздно разойдутся.

Обсудить проект

Остались вопросы? Напишите нам

Оставьте имя и телефон — перезвоним в течение часа, разберём задачу и предложим решение.

Также читайте нас в Telegram.