Next.js environment variables undefined in production: build time vs runtime.
next build replaces every process.env.NEXT_PUBLIC_* with its value at that moment. If the variable wasn’t set during the build, the browser gets undefined, and setting it later changes nothing until you rebuild. Server-only variables are read at runtime, except in pages Next.js prerenders during the build.
Facts checked
When each value gets fixed
Find the row that matches where your code reads the variable. The middle column is the moment its value is decided; anything you change after that moment is invisible to it.
| Where it’s read | Value fixed at | To change it |
|---|---|---|
NEXT_PUBLIC_* in a client component † | next build: inlined into the JavaScript bundle | Rebuild |
NEXT_PUBLIC_* in server code † | next build: inlined there too | Rebuild |
| Server variable in a dynamic page, Route Handler or Server Action † † | Each request, from process.env | Restart the server with the new value |
| Server variable in a page prerendered at build † | next build: the HTML is rendered then | Rebuild, or make the page dynamic with await connection() |
Anything read in next.config.* † | next build | Rebuild |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. `NEXT_PUBLIC_*` in a client component (2026-10-29) · `NEXT_PUBLIC_*` in server code (2026-10-29) · Server variable in a dynamic page, Route Handler or Server Action (2026-10-29) · Server variable in a dynamic page, Route Handler or Server Action (2026-10-29) · Server variable in a page prerendered at build (2026-10-29) · Anything read in `next.config.*` (2026-10-29)
Why a variable set after the build never reaches the browser
The browser has no process.env. To give client code a value, Next.js does a text replacement during next build: each literal process.env.NEXT_PUBLIC_X becomes the string it had on the build machine. The Next.js docs are blunt about it: after the build, the app “will no longer respond to changes to these environment variables.”
- Set during the build, or it doesn’t exist. A host that injects variables only when the app starts, or a Docker image built without them, produces a bundle full of
undefined. - One image, many environments doesn’t work for `NEXT_PUBLIC_`. Promote a staging build to production and the browser keeps the staging values.
- Docker: pass browser values as
ARGin the build stage, becauseENVset only in the final stage arrives afternext buildhas run. Server secrets belong in the runtime environment, not inARG, where they end up in the image history.
// What you wrote (a client component)
const api = process.env.NEXT_PUBLIC_API_URL;
// What `next build` ships to the browser
const api = "https://api.example.com";
// ...or, if the variable wasn't set when the build ran
const api = undefined;
// Never inlined: dynamic lookups stay undefined in the browser
const name = "NEXT_PUBLIC_API_URL";
process.env[name];
const env = process.env;
env.NEXT_PUBLIC_API_URL;Server variables that are still undefined
Variables without the prefix stay on the server and are read from process.env when the code runs. When one still comes back undefined, it is usually one of these:
- The page was prerendered. A page with no request-time API (
cookies(),headers(),connection()) is rendered duringnext build. Any variable it reads is baked into its HTML, and a variable that only exists at runtime isundefinedthere. Callawait connection()in the page to render it per request, as the self-hosting guide shows. - It lives in `.env.local`. That file is git-ignored by default, so it never leaves your laptop. Set the value on the host instead.
- You read it in a client component. Without
NEXT_PUBLIC_, client code getsundefinedby design. Read it in a server component and pass it down as a prop. - The `.env` file is in `src/`. Next.js loads
.env*files only from the project root.
How variables reach a Next.js build on Canner
You set variables in the project’s Env Vars tab. Each has a scope that decides which phases receive it:
- **
NEXT_PUBLIC_*must not be runtime only. That scope keeps a value out of the build, which is exactly when Next.js inlines it. The browser gets `undefined`. Use production or all**. - A runtime-only secret read in a static page is `undefined` too, for the same reason. Make that page dynamic, or move the read into a Route Handler or Server Action.
- Edits apply on the next deploy, server variables included. Each deploy carries its own copy of your variables, so redeploy after changing one. The build log confirms what went in:
[canner] wrote 12 env var(s) to .env.production (build phase). - A committed `.env.production` is replaced. Canner writes your project’s variables to
.env.productionfor the build and again for the running app. Keep shared defaults in.env, which Next.js reads last, or move them to the Env Vars tab. - Previews are their own builds. A
NEXT_PUBLIC_variable scoped preview is inlined into preview builds only, so previews can point the browser at a staging API without touching production.
| Scope | The build | Release command | Running app | Previews |
|---|---|---|---|---|
| production | Yes | Yes | Yes | No |
| preview | Preview builds | No | Preview apps | Yes |
| all | Yes | Yes | Yes | Yes |
| runtime only | No | No | Yes, production | No |
Runtime config for the browser
When a browser value must change without a rebuild, stop using NEXT_PUBLIC_ for it and serve it at runtime. Two patterns cover most apps.
Pass it from a server component. The value is read per request and handed to the client component as a prop:
// app/settings/page.tsx (server component)
import { connection } from "next/server";
import { SettingsForm } from "./settings-form"; // "use client"
export default async function Page() {
await connection(); // this page renders at request time
return <SettingsForm apiUrl={process.env.API_URL!} />;
}A public config endpoint
The same idea as an endpoint. The connection() call keeps Next.js from rendering the response once at build time:
- To check what a build actually inlined, search the client chunks for the value:
grep -rl "api.example.com" .next/static/chunks. No match means the variable was empty at build time. - If the name
NEXT_PUBLIC_API_URLstill appears in a chunk, the code reads it dynamically and it was never inlined.
// app/api/public-config/route.ts
import { connection } from "next/server";
export async function GET() {
await connection(); // read per request, not frozen at build
// Only values that are safe for anyone to see.
return Response.json({
apiUrl: process.env.API_URL,
mapsKey: process.env.MAPS_BROWSER_KEY,
});
}Questions
Why is my NEXT_PUBLIC_ variable undefined in production but fine locally?
Locally, next dev reads it from your .env file. In production it has to be set in the environment where next build runs, because it is inlined into the bundle then. Set it on the host for the build, then rebuild.
Do I need to rebuild after changing an environment variable?
For NEXT_PUBLIC_ variables, for anything read in next.config, and for pages prerendered at build: yes. Server variables read at request time only need a restart. On Canner, any change applies on the next deploy.
Can I change a NEXT_PUBLIC_ variable at runtime?
No. After the build it is a string literal in your JavaScript. For values that must change without a rebuild, read them on the server and pass them to the client, or serve them from a Route Handler.
Why doesn’t my .env.local work in production?
It isn’t deployed. .env.local is meant for your machine and is git-ignored in new Next.js projects. Put the values in your host’s environment variables instead.
Related
- GuideVite import.meta.env undefined in production
- GuideBuild failed: read the log and fix the cause
- GuideJavaScript heap out of memory during build
- GuideWhere to run database migrations in a deploy
- GuideZero-downtime deploys with health checks
- RubyRuby on Rails hosting in Canada
Deploy Next.js with your variables in Montréal.
Paste your .env into the bulk editor, pick a scope for each value, and deploy. Or set them from the CLI.