Worklog for task "Add multilingual support"
Next.js i18n Domain Routing in Local Development: Why I Had to Run the Dev Server on Port 80
When setting up the local environment for Next.js, I ran into a rather frustrating limitation of the built-in domain-based i18n routing.
The task seems simple at first glance: the application uses different domains for different locales, and you want to replicate the same setup locally. For example:
const nextConfig: NextConfig = {
i18n: {
locales: LOCALE_CODES,
defaultLocale: 'en',
localeDetection: false,
domains: [
{
domain: 'vietnamguru-v3.localhost',
defaultLocale: 'ru',
locales: ['ru'],
http: true,
},
],
},
}
Next.js officially supports this configuration. The http: true field exists partly for local testing of locale domains over HTTP instead of HTTPS.
The trouble starts with the port.
You Cannot Properly Specify a Dev Port in i18n.domains
A standard Next.js dev server runs on 3000:
http://vietnamguru-v3.localhost:3000
It would be logical to write:
domains: [
{
domain: 'vietnamguru-v3.localhost:3000',
defaultLocale: 'ru',
locales: ['ru'],
http: true,
},
]
However, i18n.domains in Next.js is designed specifically for domains, not arbitrary origins.
The current DomainLocale type looks like this:
export interface DomainLocale {
defaultLocale: string
domain: string
http?: true
locales?: readonly string[]
}
It does not have a port, an origin, or any kind of devPort.
This in itself wouldn't be so bad if Next used the domain solely for locale detection. But domain routing also affects link generation.
<Link href="/place"> Unexpectedly Becomes an Absolute Link
In the application, there is a completely ordinary link:
<Link href="/place">
Что посетить
</Link>
Without domain-based i18n, you would expect HTML roughly like this:
<a href="/place">Что посетить</a>
And the browser naturally opens:
http://vietnamguru-v3.localhost:3000/place
Meaning the current origin, including the port, is preserved automatically.
However, with domain routing enabled, Next.js knows that a specific locale belongs to a specific domain. Therefore, it can generate an absolute locale-domain URL for the Link.
As a result, you get:
<a href="http://vietnamguru-v3.localhost/place">
Что посетить
</a>
And this is where :3000 is lost.
This is especially frustrating because there is no absolute URL in the source JSX:
<Link href="/place">
The Next.js routing layer itself makes it absolute.
This leads to a paradoxical situation:
Current page:
http://vietnamguru-v3.localhost:3000/foo
JSX:
<Link href="/place">
Generated href:
http://vietnamguru-v3.localhost/place
The browser quite reasonably perceives the latter URL as HTTP on the standard port 80.
You Cannot Simply Tell Next.js: "Leave Internal Links Relative"
This is perhaps the main limitation.
In the domain i18n configuration, there is no setting like:
relativeLinks: true
or:
absoluteLocaleLinks: false
There is no way to specify:
port: 3000
and there is no separate dev-origin:
origin: 'http://vietnamguru-v3.localhost:3000'
In other words, the configuration model essentially assumes that the locale domain is available on the standard port of the corresponding protocol.
For production, this is completely normal:
https://example.com
https://example.fr
For local development:
http://example.localhost:3000
— is already a problem.
Why http: true Does Not Solve the Problem
The field name initially gives hope:
{
domain: 'vietnamguru-v3.localhost',
http: true,
}
But it only handles choosing the scheme:
https://
or:
http://
Meaning Next gets enough information to construct:
http://vietnamguru-v3.localhost/place
But information that the local server is on 3000 simply does not exist in this model.
The Reverse Proxy Approach
Architecturally, the cleanest solution is to place a local reverse proxy in front of Next:
http://vietnamguru-v3.localhost
|
v
localhost:3000
For example, via nginx, Caddy, or another local proxy.
Then Next continues to generate:
http://vietnamguru-v3.localhost/place
and this URL actually works.
However, for my current dev environment, this is extra infrastructure just to bypass a framework limitation.
Therefore, a temporary solution turned out to be simpler: running Next.js itself directly on port 80.
Running Next.js on Port 80
The launch itself is elemental:
PORT=80 npm run dev
After that:
http://vietnamguru-v3.localhost
truly is the address of the dev server, and the absolute links generated by Next.js start working correctly.
But the next problem arises.
A Regular User Cannot Listen on Port 80
On Linux, ports below 1024 are traditionally privileged ports.
Therefore, a regular:
PORT=80 npm run dev
may end with a permission error when Node.js attempts to bind to port 80.
The first obvious thought:
sudo npm run dev
But this is a bad option in itself, and in my case, it's also practically unworkable: Node/npm are not installed globally in the system environment.
For example, if Node is managed by a user version manager, the npm visible to the current shell does not necessarily exist in the sudo environment.
You get a classic situation:
npm run dev
works, but:
sudo npm run dev
— does not, or launches an entirely different Node environment.
And running the entire dev server as root just for the ability to open a single port is still undesirable.
CAP_NET_BIND_SERVICE Instead of Running Node as Root
In Linux, the CAP_NET_BIND_SERVICE capability exists for this purpose.
It allows a specific executable to open privileged network ports without running the entire process as root.
For the current Node executable:
which node
you can grant the capability:
sudo setcap 'cap_net_bind_service=+ep' $(which node)
After that, Node can continue to be run as a regular user:
PORT=80 npm run dev
and the process will be able to listen on port 80.
As a result, the local schema becomes:
vietnamguru-v3.localhost
|
| :80
v
Next.js dev
while Next.js domain routing generates:
http://vietnamguru-v3.localhost/place
which now matches the actual address of the application.
Why This Is Still a Workaround
Granting CAP_NET_BIND_SERVICE to node itself is not an ideal universal solution.
The capability is assigned to the Node.js executable, not a specific Next.js project. Consequently, any process launched via that specific Node binary from this environment gets the ability to bind to privileged ports.
Furthermore, if Node is installed via a version manager and the Node version is switched or reinstalled, the path to the executable may change. The capability would then need to be reassigned to the new binary.
You can check current capabilities like this, for example:
getcap $(which node)
Expected result:
/path/to/node cap_net_bind_service=ep
If necessary, the capability can be removed:
sudo setcap -r $(which node)
Therefore, this is specifically a convenient local workaround, not a setting that should be thoughtlessly deployed to all development environments.
Conclusion
The problem turned out not to be DNS, /etc/hosts, React, or browser behavior.
It arises from a combination of several Next.js domain-based i18n features:
i18n.domainsdescribes the hostname but does not provide a separate port setting.http: truelets you choose HTTP instead of HTTPS, but does not specify a dev port.- With domain routing, Next.js can transform a regular
<Link href="/...">into an absolute link to the locale domain. - There is no separate toggle in the configuration to keep such internal links relative.
- Therefore, the standard Next dev port
3000does not mix well with local emulation of domain-based locale routing.
In my case, the temporary solution turned out to be:
sudo setcap 'cap_net_bind_service=+ep' $(which node)
PORT=80 npm run dev
After that, the local hostname can be used without an explicit port:
http://vietnamguru-v3.localhost
and the absolute Next.js locale-domain links match the actual origin.
It works. But it has quite a few infrastructural consequences for something that at the application level looks simply like:
<Link href="/place">
Sources
The official Next.js Pages Router documentation confirms built-in domain routing support, the domains structure, and the purpose of http: true specifically for local HTTP testing.
The current DomainLocale type in vercel/next.js contains domain, defaultLocale, locales, and http, but lacks a separate port setting.
In the Link documentation, standard internal transitions are still specified using relative pathnames (/, /about, /blog/...), meaning you don't need to write the absolute locale-domain URL in the application's JSX.