Skip to content

Vaynerov Technologies

Мы не просто разрабатываем — мы создаём каждую строку кода и пиксель.

Все статьиРазборы

Шесть языков, одна кодовая база: i18n в Next.js как архитектура

vaynerov.com/de/leistungen — это не английская страница с прикрученным переключателем языка. Это другой URL, другой каталог, другой набор альтернатив hreflang и тщательно отмеренная порция JavaScript. Вот архитектура, лежащая под шестью языками.

Vaynerov TechnologiesСтудия
Опубликовано Обновлено 9 мин чтения
На этой странице
  1. Слаг — часть перевода
  2. Одна прослойка, три обхода
  3. 1,19 МБ каталогов сообщений и русская проблема
  4. До браузера доезжают одиннадцать пространств имён
  5. Переводы, которые меняются без деплоя
  6. Откаты, hreflang и что достаётся краулерам

Посмотрите на URL: vaynerov.com/de/leistungen. Английского в нём нет ничего. Сегмент локали говорит «немецкий», и слаг говорит то же самое — Leistungen, услуги. Посетитель, пришедший из немецкой выдачи, не увидит в адресной строке ни одного английского слова, а краулер, индексирующий эту страницу, индексирует немецкий путь, а не английский, переодетый в ?lang=de.

Именно это решение — что слаг является частью перевода — и делает интернационализацию на этом сайте задачей архитектуры, а не задачей перевода. Перевод — это таблица. Архитектура начинается там, где один канонический маршрут должен стать шестью URL, шестью записями в sitemap, шестью альтернативами hreflang и одним общим деревом React, не удваивая код и не утраивая байты, доезжающие до телефона.

6
локалей — en, ru, es, de, fr, pt
28
локализованных записей путей
1.19 MB
каталогов сообщений на диске
11
пространств имён, уезжающих в браузер

Измеренная форма слоя i18n: что маршрутизируется, что хранится и какую малую долю от этого браузер вообще скачивает.

Слаг — часть перевода

Мы работаем на next-intl с localePrefix: "as-needed" и английским как локалью по умолчанию. Английские URL не несут префикса вовсе — /services это каноническая страница услуг, — а остальные пять локалей всегда с префиксом. Обмен намеренный: as-needed стоит решения о маршрутизации на каждом запросе и покупает чистые канонические URL без префикса для той локали, на которую указывает большинство ссылок.

Поверх префикса лежит карта путей: 28 записей, каждая связывает один внутренний маршрут с шестью публичными слагами. Услуги, работы, цены, журнал, о нас, карьера, инвесторам, контакты, запрос сметы, конфиденциальность и условия — переводится всё.

const pathnames = {
  "/services": {
    en: "/services",
    ru: "/uslugi",
    es: "/servicios",
    de: "/leistungen",
    fr: "/services",
    pt: "/servicos",
  },
  // …27 more
};

Один маршрут, шесть URL. Французский случайно попадает в то же слово, что и английский, — и это безвредно, потому что префикс локали снимает неоднозначность: /services и /fr/services разные страницы.

Маршрутruesdefrpt
servicesuslugiserviciosleistungenservicesservicos
workrabotytrabajosprojekterealisationsprojetos
pricingtsenypreciospreisetarifsprecos
articlesstatiarticulosartikelarticlesartigos

Четыре из 28 записей карты путей. Русские слаги — латинские транслитерации, поэтому любой путь на сайте остаётся ASCII и в строке лога, и в поисковой выдаче, и в дашборде аналитики.

Интереснее в этой таблице то, чего в ней нет. Несколько маршрутов не переводятся никогда: /museum, /games, /roadmap, /cookies, /nda. Причины две, и обе стоит проговорить вслух. Кодовые имена продуктов — это имена: Reliquary называется Reliquary на любом языке, и немецкий посетитель ищет его по тому имени, которое мы ему дали, а не по переводу. С юридической рутиной случай обратный: эти страницы меняются редко, их читают юристы и краулеры чаще, чем люди, и стабильный путь во всех шести локалях стоит больше локализованного.

Один маршрут на входе, шесть URL на выходе. Английский пропускает префикс по политике; кодовые имена продуктов и юридические страницы по политике пропускают перевод.

Одна прослойка, три обхода

Каждый запрос встречает самописный роутер, обёрнутый вокруг createIntlMiddleware. Большинство проходит прямо в согласование локали. Три префикса — нет. /admin* уходит в updateSession, чтобы обновилась cookie авторизации Supabase; /api* и /pitch* передаются в next() нетронутыми, потому что ни тому ни другому незачем перенаправляться в локаль, о которой их никто не просил. Сам матчер исключает _next и всё, где есть точка, так что статические ассеты прослойку вообще не будят.

Обход, который окупается, — сессионный, и рассуждение лежит в комментарии прямо рядом: ни одна публичная страница не читает сессию Supabase, поэтому гонять updateSession на публичном трафике значило бы покупать круг к авторизации на каждый запрос и больше ничего. Это намеренная связанность, а не недосмотр: в тот день, когда публичной странице понадобится залогиненный пользователь, именно этот комментарий придётся пересмотреть первым, — ровно поэтому там комментарий, а не молчание.

1,19 МБ каталогов сообщений и русская проблема

Шесть каталогов JSON держат каждую строку сайта. Размеры у них разные.

ЛокальБайт в каталогеК английскому
en170 069
es186 854+10%
pt187 552+10%
de188 041+11%
fr193 393+14%
ru260 610+53%

Размеры каталогов сообщений, измеренные через wc -c. Всего по шести локалям: 1 186 519 байт.

Четыре перевода из пяти кучкуются на десять-четырнадцать процентов выше английского — примерно такое расширение любой романский или германский перевод даёт бесплатно. Русский стоит на +53%, и о причине стоит сказать точно, потому что число подталкивает к неверному выводу: изрядная часть этого разрыва — арифметика UTF-8. Кириллический символ стоит двух байтов там, где латинский стоит одного, поэтому русская фраза той же длины весит вдвое больше ещё до того, как в ней появится хоть одно лишнее слово. Русский к тому же и правда многословнее английского — но счёт байтов меряет кодировку и прозу вместе, и притворяться иначе значило бы заставить наши собственные графики нам врать.

Размер важен из-за способа загрузки каталогов: конфигурация запроса импортирует их целиком, на каждый запрос. Для строк интерфейса это отличная сделка и ужасная — для длинных текстов, и именно поэтому статья, которую вы читаете, вообще не живёт в каталоге сообщений. Материалы журнала — это типизированные модули контента в src/content/articles/<slug>/<locale>.ts, которые импортируют только маршруты статей. Длинные тексты в каталогах облагали бы налогом каждую страницу сайта ради текста, который появляется на одной.

До браузера доезжают одиннадцать пространств имён

Серверные компоненты переводятся на сервере; строки не покидают рендер. Клиентские так не умеют, а наивная починка — отдать NextIntlClientProvider весь каталог — сериализовала в RSC-нагрузку каждой страницы больше 10 КБ строк, которые ни один клиентский компонент на этой странице никогда не прочитает.

Поэтому провайдер получает подмножество, отобранное вручную. Хелпер pickMessages разрешает пути с точками по каталогу, а единственный список GLOBAL_CLIENT_NAMESPACES — одиннадцать записей — решает, что браузеру позволено увидеть. Часть записей это не целые пространства имён, а отдельные листья: из pages.services уезжает всего три листа, потому что три нужны интерактивному компоненту, а остальное этого пространства рендерится на сервере. Процедура поддержания списка в честности — это grep, задокументированный в файле рядом со списком, и это самая неказистая и самая надёжная форма документации, какую мы знаем.

Каждая строка, доехавшая до браузера, находится там потому, что кто-то так решил. Умолчания «отправляем всё» не существует.

Исключение — Лаборатория. Игровые компоненты насыщены текстом так, как маркетинговые страницы не бывают: пространство games разрастается примерно до 150 ключей на одну игру, — и ничему из этого не место в глобальном списке разрешений. Поэтому поддерево игр монтирует собственный вложенный провайдер со своим пространством имён, и вот тут живёт по-настоящему мерзкая ловушка.

Переводы, которые меняются без деплоя

Статические каталоги — это пол, а не потолок. Тексты поверхностей, управляемых CMS, можно переопределить из базы, поэтому правка формулировки на испанском не требует сборки. Слияние сделано подчёркнуто аккуратно: переопределения применяются через setNestedValue к structuredClone импортированного каталога, но никогда к самому объекту модуля — этот объект общий для всех запросов в процессе, и мутация сделала бы переопределение одного посетителя переопределением для всех. Чтение сидит за unstable_cache с тегом i18n-overrides и пятиминутным окном, а записи из админки сбивают тег, а не ждут его истечения.

Если база недоступна, голый catch откатывает на статический JSON, и страница рендерится. Это правильное поведение, и оно же породило лучший баг в истории этой системы.

Вызов cookies() внутри unstable_cache в Next 15+ бросает исключение. Кешированный читатель переопределений делал ровно это. Исключение падало в голый catch, отдавался статический каталог, каждая страница выглядела безупречно — а функция переопределений была молча мертва.

Переопределения прекрасно сохранялись. Прекрасно кешировались. Они просто никогда не появлялись, и нигде ничто не сообщало об ошибке, потому что откат делал ровно ту работу, ради которой был написан. Починка маленькая — чтение переопределений использует публичный клиент Supabase без cookies, которому читать cookies и не положено, — но урок пережил её. Молчаливый откат мы теперь считаем недоделанным: если catch глотает отказ, меняющий то, что видит пользователь, он обязан оставить где-то след, иначе это не страховочная сетка, а повязка на глаза.

Откат, который не может сообщить, что он сработал, рано или поздно спрячет мёртвую функцию.

Одно следствие переопределений дотягивается обратно до клиентской нагрузки: провайдер питается от getMessages(), а не от сырого импорта JSON. Импорт JSON напрямую был бы чуть быстрее и заодно полностью обходил бы слияние, так что клиентские компоненты рисовали бы текст до переопределения, пока серверная половина той же страницы показывала бы исправленный. Два источника истины на одном экране хуже, чем один медленный.

Откаты, hreflang и что достаётся краулерам

Покрытие переводом никогда не бывает равномерным. Секция CMS может существовать на английском и немецком, но ещё не на португальском, и честные варианты здесь — пустая область или английский текст. Мы выбрали английский, на уровне контента: fetchSectionWithFallback, fetchRowsWithFallback и fetchPageMeta пробуют запрошенную локаль, при промахе повторяют попытку по-английски и коротят повтор, когда запрошенная локаль уже и есть английский. Частично переведённая страница деградирует до двуязычной, а не до сломанной.

Карта альтернатив обо всём этом не знает, и это намеренно. alternates.ts объявляет набор URL — шесть записей hreflang плюс x-default, указывающий на английский URL, — и больше ничего. Подмешать сюда логику отката значило бы, что граф hreflang меняет форму в зависимости от того, как далеко продвинулся переводчик, а это отличный способ научить поисковик, что структура ваших URL нестабильна. Покрытие выражается там, где ему место: в sitemap альтернативы hreflang для страниц работ и материалов журнала ограничены теми локалями, у которых действительно есть опубликованная запись.

У локализованных слагов есть ещё одно следствие на границе с краулерами, которого в первый раз никто не предвидит. Поток запроса сметы запрещён в robots.txt — это воронка, а не посадочная страница, — и, поскольку слаг локализован, запрещать нужно не один путь. Их шесть. Добавьте, что группы в robots.txt независимы друг от друга (запрет, написанный в одной группе, не действует в следующей) и что мы разрешаем восемнадцать ИИ-краулеров восемнадцатью отдельными группами, — и список запретов приходится повторять целиком, каждый раз, в каждой группе. Он генерируется, а не набирается руками, по причинам, которые должны быть очевидны.

Умножение вылезает и в sitemap. Двадцать статических страниц превращаются в 120 записей ещё до того, как посчитана хоть одна игра, кейс или материал журнала, и каждая запись несёт собственный блок альтернатив. Sitemap такой формы руками не поддерживает никто; это функция от карты путей — и по той же причине карте путей позволено быть единственным местом, где записан слаг. Всё, что нужно повторять в шести местах, рано или поздно будет повторено в пяти.

Последняя миля — социальные метаданные, где шесть локалей превращаются в карту Open Graph: en_US, ru_RU, es_ES, de_DE, fr_FR, pt_PT. Её выпускает общий хелпер, который всегда переобъявляет каждое поле, и это не стилистика: Next.js заменяет, а не глубоко сливает openGraph на каждом сегменте маршрута, поэтому любая страница, задающая хоть одно своё поле, молча теряет умолчания layout локали, унося с собой og:site_name и og:locale. Хелпер существует ради того, чтобы никому не приходилось об этом помнить.

Ничего экзотического выше нет. Держится всё это вместе потому, что каждая из шести локалей считается полноценной поверхностью с одинаковыми гарантиями — свой URL, свой каталог, свои метаданные, своя история для краулеров, — а не английским с таблицей подстановки впереди. Мерилом тут служит не запуск, а стоимость двадцать девятой записи в карте путей: одна строка в карте, шесть слагов, одна строка в sitemap и никаких споров. О том, почему мы держим эту планку, мы писали отдельно; а вот как она выглядит, когда планка — это таблица маршрутизации.