Next.js Environment Variables: Build Time vs Runtime
09 Jan 2026Your .env.production has the right value. Your container has the right value. And process.env.NEXT_PUBLIC_API_URL still shows up as undefined in the browser. This is the single most common self-hosting bug in Next.js, and it’s not a bug — it’s how the build works.
Two kinds of env var, two different lifetimes
Next.js splits environment variables into two categories that behave completely differently:
NEXT_PUBLIC_* |
Everything else | |
|---|---|---|
| Where it runs | Inlined into client JS at build time | Read at runtime, server only |
| When it’s “locked in” | The moment next build runs |
Every time the process starts |
| Changing it after build | Does nothing — rebuild required | Takes effect on next restart |
| Visible in browser devtools | Yes, by design | Never (and never should be) |
A server-only variable like DATABASE_URL is read fresh from process.env every time your server code runs, so setting it at container start with docker run -e or your platform’s secrets manager works fine.
A NEXT_PUBLIC_* variable gets baked directly into the compiled JavaScript during next build. There is no process.env lookup left in the shipped bundle — it’s a literal string substitution. If the variable wasn’t set during the build step, it’s undefined forever, and no amount of setting it at runtime will fix it.
Why this trips people up specifically with Docker
The natural instinct with containers is “build once, configure per-environment with env vars at deploy time.” That’s correct for server secrets and wrong for NEXT_PUBLIC_* values.
# This does NOT work for NEXT_PUBLIC_* vars:
FROM node:20-alpine
COPY . .
RUN npm run build
CMD ["node", "server.js"]
# Setting it here is too late — the bundle is already built
docker run -e NEXT_PUBLIC_API_URL=https://api.example.com myapp
The fix is to pass public values as build args, so they exist when next build runs:
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 -t myapp .
The consequence: one image per environment
Because NEXT_PUBLIC_* values are baked in, you can’t build one Docker image and promote it unchanged from staging to production if any public var differs between them. You either:
- Build a separate image per environment, with the right build args each time, or
- Keep public values identical across environments (same API gateway host, different backend routing behind it) and only vary server-side secrets at runtime.
Option 2 is usually the better long-term setup — it lets you promote a single tested artifact instead of rebuilding per environment, which reintroduces “works in staging, breaks in prod because the build was different” risk.
What to actually put in NEXT_PUBLIC_*
Given it ends up readable in the browser, treat it as public information regardless of intent:
- API base URLs, feature flag keys, analytics IDs: fine.
- Any secret, token, or key that grants access: never. If it starts with
NEXT_PUBLIC_, assume every visitor can read it in devtools, because they can.
The takeaway
If a value needs to reach client-side JavaScript, it must exist at next build time — pass it as a Docker build arg, not a runtime env var. If a value is server-only, runtime env vars work exactly as you’d expect. Confusing which bucket a variable belongs to is the whole bug.