A deploy went out. The site loaded, the certificate was valid, the server was in a datacentre
in London - and every API call in the browser console was going to
http://localhost:5080.
Nothing in .env.production mentioned localhost. The server's own environment did
not mention localhost. The value had been welded into the JavaScript bundle at build time, from a
file I had forgotten was on my laptop: .env.local.
If you have ever searched "Next.js not reading .env.local", or wondered whether
.env.local overrides .env.production, the answer is the one that catches
people out:
Next.js loads .env.local during next build, not just
next dev - and it outranks .env.production.
The precedence order, and the one line that matters
Next.js merges up to five sources. The first one that defines a variable wins; later sources cannot overwrite it:
process.env (real shell / service environment)
.env.$(NODE_ENV).local (.env.production.local on a prod build)
.env.local (skipped ONLY when NODE_ENV=test)
.env.$(NODE_ENV) (.env.production on a prod build)
.env
Read line three against line four. .env.local sits above
.env.production. There is exactly one condition under which Next.js skips it, and it
is not "production" - it is NODE_ENV=test.
That single ordering decision is the whole bug. The file most people think of as "my local development overrides" is, during a production build, a higher-priority source than the file literally named for production.
Prove it on your own project in ten lines
You do not have to trust an article about this, and you should not. Next.js publishes its env
loader as a standalone package, @next/env - the same code next build
calls. You can run it directly.
Three fixture files:
# .env
NEXT_PUBLIC_API_URL=https://api.example.com
# .env.production
NEXT_PUBLIC_API_URL=https://api.prod.example.com
# .env.local
NEXT_PUBLIC_API_URL=http://localhost:5080
And a script that asks the loader what it did:
const { loadEnvConfig } = require('@next/env');
process.env.NODE_ENV = 'production';
const r = loadEnvConfig(process.cwd(), false); // false = not dev
r.loadedEnvFiles.forEach(f => console.log('loaded:', f.path));
console.log('winner:', process.env.NEXT_PUBLIC_API_URL);
On Next.js 16.3.3 that prints:
loaded: .env.local
loaded: .env.production
loaded: .env
winner: http://localhost:5080
A production build, with a .env.production sitting right there, resolving a public
API URL to localhost. No warning, no error, exit code 0.
Why NEXT_PUBLIC_ turns a slip into a permanent artifact
Next.js treats two kinds of variable very differently, and the difference decides whether this is a five-second fix or a rebuild.
- Server-only variables (no
NEXT_PUBLIC_prefix), read in Server Components, route handlers and other server code, are read from the process environment at runtime. NEXT_PUBLIC_variables are inlined at build time. The compiler replacesprocess.env.NEXT_PUBLIC_API_URLwith the literal string and ships it inside the chunk the browser downloads.
So after a bad build, the wrong value is not sitting in config anywhere. It is text inside a
.js file:
grep -rl "localhost:5080" .next/static/chunks | head
If that returns anything, no amount of fixing the server's environment will help. This is why the follow-up question is always some version of "I corrected the variable and restarted the app and it still calls localhost". Of course it does. You changed the server; the string is in the browser's download. Only a rebuild moves it.
Why it breaks on your machine and not in CI
This bug has a very specific shape: it follows the person, not the project.
create-next-app adds .env*.local to .gitignore by
default. In this repository the ignore rule is even broader - line 35 is just
.env*. Which means:
- A clean CI checkout has no
.env.local. Builds there are correct, every time. - A developer machine has one, because that is what it is for. Builds there silently pick it up.
If your pipeline builds in CI, you may never meet this. If you build locally and ship the
artifact - zip the output, upload it through a hosting panel, copy a folder to a VPS - you are
exactly the person it happens to. That is how this site shipped it. The build ran on a laptop that
had a perfectly reasonable .env.local pointing at a local API, and the resulting zip
went to production with that value fossilised inside it.
It is the same class of problem as deploying in the wrong order, which I wrote about in choosing between a Windows and Linux VPS for ASP.NET Core: nothing throws, the artifact is valid, and the mistake only becomes visible to real users.
The fix is a rename, not a rule
The obvious reaction is "delete .env.local before building". That works exactly
until the first time somebody forgets, which is to say it does not work.
Rename the file instead:
mv .env.local .env.development.local
Go back to the precedence list. .env.$(NODE_ENV).local is only consulted when
NODE_ENV matches, so .env.development.local is invisible to a production
build while still beating everything during next dev. Same fixture files, same
loader, after the rename:
NODE_ENV=production
loaded: .env.production, .env
winner: https://api.prod.example.com
NODE_ENV=development
loaded: .env.development.local, .env
winner: http://localhost:5080
Development keeps its overrides. The production build cannot see them even if it wants to. No checklist, no discipline, nothing to remember at 11pm before a deploy - which is the only kind of fix worth having.
What you can still change without rebuilding
Because the two variable types behave differently, "can I change this without a rebuild?" has a clean answer:
| Variable | Resolved | Change it by |
|---|---|---|
NEXT_PUBLIC_* | Build time, inlined into client JS | Rebuilding. Nothing else. |
| Everything else, used in server code | Runtime, from the process environment | Updating the service env and restarting. |
With output: "standalone" the server reads its environment when the Node process
boots, so database connection strings, API keys and third-party secrets really are runtime
configuration. You can rotate a key and restart. You cannot rotate a
NEXT_PUBLIC_ value that way, ever.
The practical rule: anything the browser can see is a build input, not a config knob. If you genuinely need a browser-visible value to differ per environment without rebuilding, do not reach for an env var - expose it from a small API route and fetch it at runtime. That is a deliberate design decision with a cost, and it is better than discovering the constraint during an incident.
The Docker version of the same trap
Containerised builds hit this twice, and the second one surprises people who thought they had understood the first.
The first is timing. In a Dockerfile, next build runs while the image is
being built. Anything you pass at docker run - -e, --env-file,
a Compose environment: block, a Kubernetes ConfigMap - arrives long after the bundle
was written. Server-side variables are fine, because the server reads them when the process starts.
NEXT_PUBLIC_ values are not, so they have to be build arguments:
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
docker build --build-arg NEXT_PUBLIC_API_URL=https://api.example.com .
The practical consequence is one people dislike: a NEXT_PUBLIC_ value that differs
between staging and production means two different images. One image promoted
through environments cannot carry two different public API URLs. If that matters to you, serve the
value from an API route instead and keep the image environment-agnostic.
The second trap is the one that actually bites. .gitignore and
.dockerignore are separate files, and the Next.js starter gives you a thorough
.gitignore and a thin .dockerignore. So this very ordinary line:
COPY . .
happily copies your local .env.local into the build context, where
next build finds it and ranks it above .env.production - exactly as it
did on my laptop. You get the localhost bug inside a container that was supposed to be
reproducible, and because the file is invisible in Git nobody looking at the repository can see
why. Add .env*.local to .dockerignore, or apply the rename and stop
worrying about which ignore file is which.
Which env files should actually be committed
Since none of this is obvious from the filenames, here is the whole set in one table:
| File | Commit it? | What it is for |
|---|---|---|
.env | Usually yes | Defaults shared by every environment. No secrets. |
.env.production | Yes | Non-secret production values, such as a public API URL. |
.env.development.local | No | Your personal dev overrides. Invisible to production builds. |
.env.local | No - and prefer not to have one | Overrides every environment, including production builds. |
.env.example | Yes | Documentation. Every key, no real values. |
Real secrets - database passwords, payment keys, tokens - should not be in any committed file.
They belong in the hosting platform's environment settings, where they are read at runtime and can
be rotated without a build. The committed files exist to answer "which variables does this app
need?", which is what .env.example is for, and the question new developers waste
their first morning on.
A sixty-second audit
Worth doing once on any Next.js project you deploy:
ls -a | grep env- list every env file that exists locally. Anything named.env.localor.env.production.localis a live hazard on a local build.- Run the
@next/envsnippet above withNODE_ENV=productionand read which files it loads. Takes five seconds and removes all doubt. - After your next build, grep the output for values that should never reach production:
grep -rl "localhost" .next/static/chunks. - Make step 3 a build step rather than a habit. A grep that exits non-zero is a better teammate than a wiki page.
That last point is the general lesson. The staging script for this site asserts that a handful of required files are present before it will produce a deployable folder, because every one of those assertions exists thanks to something that went wrong silently once. Build-time checks are cheap. Finding out from a user that your production site is calling localhost is not.
If you are putting a Next.js front end over an existing .NET backend, the same build-versus- runtime split shapes a lot of the architecture - I went through that in detail in migrating an ASP.NET MVC monolith to a React front end.







Join the conversation
No comments yet — be the first to share what you think.