Ворклог по задаче "Добавить мультиязычность"

11 авг. 2026 г., 05:49:00

Next.js i18n domain routing в локальной разработке: почему пришлось запускать dev-сервер на 80-м порту

При настройке локального окружения для Next.js я столкнулся с довольно неприятным ограничением встроенного domain-based i18n routing.

Задача на первый взгляд простая: приложение использует разные домены для разных локалей, и локально хочется воспроизвести ту же схему. Например:

const nextConfig: NextConfig = {
  i18n: {
    locales: LOCALE_CODES,
    defaultLocale: 'en',
    localeDetection: false,
    domains: [
      {
        domain: 'vietnamguru-v3.localhost',
        defaultLocale: 'ru',
        locales: ['ru'],
        http: true,
      },
    ],
  },
}

Next.js официально поддерживает такую конфигурацию. Поле http: true существует в том числе для локального тестирования locale domains по HTTP вместо HTTPS.

Проблема начинается с порта.

В i18n.domains нельзя нормально указать dev-порт

Обычный Next.js dev server работает на 3000:

http://vietnamguru-v3.localhost:3000

Логично было бы написать:

domains: [
  {
    domain: 'vietnamguru-v3.localhost:3000',
    defaultLocale: 'ru',
    locales: ['ru'],
    http: true,
  },
]

Но i18n.domains в Next.js рассчитан именно на домены, а не на произвольные origins.

Актуальный тип DomainLocale выглядит так:

export interface DomainLocale {
  defaultLocale: string
  domain: string
  http?: true
  locales?: readonly string[]
}

У него нет ни port, ни origin, ни какого-либо devPort.

Это само по себе было бы не так страшно, если бы Next использовал домен только для определения locale. Но domain routing влияет ещё и на генерацию ссылок.

<Link href="/place"> неожиданно становится абсолютной ссылкой

В приложении совершенно обычная ссылка:

<Link href="/place">
  Что посетить
</Link>

Без domain-based i18n от неё ожидаешь HTML примерно такого вида:

<a href="/place">Что посетить</a>

И браузер естественным образом открывает:

http://vietnamguru-v3.localhost:3000/place

То есть текущий origin, включая порт, сохраняется автоматически.

Но при включённом domain routing Next.js знает, что конкретная locale относится к конкретному домену. Поэтому он может сформировать для Link абсолютный locale-domain URL.

В результате получается:

<a href="http://vietnamguru-v3.localhost/place">
  Что посетить
</a>

И вот здесь :3000 уже потерян.

Это особенно неприятно потому, что в исходном JSX никакого абсолютного URL нет:

<Link href="/place">

Абсолютным его делает сам routing layer Next.js.

Получается парадоксальная ситуация:

Текущая страница:
http://vietnamguru-v3.localhost:3000/foo

JSX:
<Link href="/place">

Сгенерированный href:
http://vietnamguru-v3.localhost/place

Браузер совершенно справедливо воспринимает последний URL как HTTP на стандартном порту 80.

Нельзя просто сказать Next.js: «оставь внутренние ссылки относительными»

Вот это, пожалуй, главное ограничение.

В конфигурации domain i18n нет настройки в духе:

relativeLinks: true

или:

absoluteLocaleLinks: false

Нет возможности задать:

port: 3000

и нет отдельного dev-origin:

origin: 'http://vietnamguru-v3.localhost:3000'

То есть модель конфигурации фактически предполагает, что locale domain доступен на стандартном порту соответствующего протокола.

Для production это совершенно нормально:

https://example.com
https://example.fr

Для локальной разработки:

http://example.localhost:3000

— уже проблема.

Почему http: true проблему не решает

Название поля сначала даёт надежду:

{
  domain: 'vietnamguru-v3.localhost',
  http: true,
}

Но оно отвечает только за выбор схемы:

https://

или:

http://

То есть Next получает достаточно информации, чтобы построить:

http://vietnamguru-v3.localhost/place

Но информации о том, что локальный сервер находится на 3000, в этой модели просто нет.

Вариант с reverse proxy

Архитектурно наиболее чистое решение — поставить перед Next локальный reverse proxy:

http://vietnamguru-v3.localhost
              |
              v
        localhost:3000

Например, через nginx, Caddy или другой локальный proxy.

Тогда Next продолжает генерировать:

http://vietnamguru-v3.localhost/place

и этот URL действительно работает.

Но для моего текущего dev environment это лишняя инфраструктура ради обхода ограничения framework.

Поэтому временное решение оказалось проще: запускать сам Next.js непосредственно на 80-м порту.

Запускаем Next.js на 80-м порту

Сам запуск элементарный:

PORT=80 npm run dev

После этого:

http://vietnamguru-v3.localhost

действительно является адресом dev server, и абсолютные ссылки, генерируемые Next.js, начинают работать правильно.

Но появляется следующая проблема.

Обычный пользователь не может слушать порт 80

На Linux порты ниже 1024 традиционно являются privileged ports.

Поэтому обычный:

PORT=80 npm run dev

может закончиться ошибкой доступа при попытке Node.js забиндиться на порт 80.

Первая очевидная мысль:

sudo npm run dev

Но это плохой вариант сам по себе, а в моём случае ещё и практически нерабочий: Node/npm установлены не глобально в системное окружение.

Например, если Node управляется пользовательским version manager'ом, npm, который видит текущий shell, вовсе не обязательно существует в окружении sudo.

Получается классическая ситуация:

npm run dev

работает, а:

sudo npm run dev

— уже нет или запускает вообще другое Node-окружение.

И запускать весь dev server от root только ради возможности открыть один порт всё равно не хочется.

CAP_NET_BIND_SERVICE вместо запуска Node от root

В Linux для этого существует capability CAP_NET_BIND_SERVICE.

Она позволяет конкретному executable открывать privileged network ports без запуска всего процесса от root.

Для текущего Node executable:

which node

можно выдать capability:

sudo setcap 'cap_net_bind_service=+ep' $(which node)

После этого Node можно продолжать запускать от обычного пользователя:

PORT=80 npm run dev

и процесс сможет слушать порт 80.

В результате локальная схема становится такой:

vietnamguru-v3.localhost
        |
        | :80
        v
   Next.js dev

а Next.js domain routing генерирует:

http://vietnamguru-v3.localhost/place

что теперь соответствует реальному адресу приложения.

Почему это всё-таки workaround

Выдача CAP_NET_BIND_SERVICE самому node — не идеальное универсальное решение.

Capability назначается executable Node.js, а не конкретному Next.js-проекту. Следовательно, любой процесс, запущенный через этот конкретный Node binary от данного окружения, получает возможность bind'иться к privileged ports.

Кроме того, если Node установлен через version manager и версия Node переключается или переустанавливается, путь к executable может измениться. Capability тогда придётся назначать новому бинарнику заново.

Проверить текущие capabilities можно, например, так:

getcap $(which node)

Ожидаемый результат:

/path/to/node cap_net_bind_service=ep

При необходимости capability можно удалить:

sudo setcap -r $(which node)

Поэтому это именно удобный локальный workaround, а не настройка, которую стоит бездумно распространять на все development environments.

Что в итоге

Проблема оказалась не в DNS, /etc/hosts, React или поведении браузера.

Она возникает из сочетания нескольких особенностей Next.js domain-based i18n:

  1. i18n.domains описывает hostname, но не предоставляет отдельной настройки порта.
  2. http: true позволяет выбрать HTTP вместо HTTPS, но не задаёт dev-port.
  3. При domain routing Next.js может преобразовывать обычный <Link href="/..."> в абсолютную ссылку на locale domain.
  4. В конфигурации нет отдельного переключателя, позволяющего сохранить такие внутренние ссылки относительными.
  5. Поэтому стандартный Next dev port 3000 плохо сочетается с локальной эмуляцией domain-based locale routing.

В моём случае временное решение получилось таким:

sudo setcap 'cap_net_bind_service=+ep' $(which node)
PORT=80 npm run dev

После этого локальный hostname можно использовать без явного порта:

http://vietnamguru-v3.localhost

и абсолютные locale-domain ссылки Next.js совпадают с фактическим origin.

Работает. Но довольно много инфраструктурных последствий для вещи, которая на уровне приложения выглядит всего лишь как:

<Link href="/place">

Источники

Актуальная документация Next.js по Pages Router подтверждает встроенную поддержку domain routing, структуру domains и назначение http: true именно для локального HTTP-тестирования.

Текущий тип DomainLocale в vercel/next.js содержит domain, defaultLocale, locales и http, но отдельной настройки порта в нём нет.

В документации по Link обычные внутренние переходы по-прежнему задаются относительными pathname (/, /about, /blog/...), то есть абсолютный locale-domain URL не требуется писать в JSX приложения.

31.07.2026