Part of Hosting by frameworkNext.js · Troubleshooting

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 readValue fixed atTo change it
NEXT_PUBLIC_* in a client component †next build: inlined into the JavaScript bundleRebuild
NEXT_PUBLIC_* in server code †next build: inlined there tooRebuild
Server variable in a dynamic page, Route Handler or Server Action † †Each request, from process.envRestart the server with the new value
Server variable in a page prerendered at build †next build: the HTML is rendered thenRebuild, or make the page dynamic with await connection()
Anything read in next.config.* †next buildRebuild

† 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 ARG in the build stage, because ENV set only in the final stage arrives after next build has run. Server secrets belong in the runtime environment, not in ARG, 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 during next build. Any variable it reads is baked into its HTML, and a variable that only exists at runtime is undefined there. Call await 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 gets undefined by 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.production for 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.
ScopeThe buildRelease commandRunning appPreviews
productionYesYesYesNo
previewPreview buildsNoPreview appsYes
allYesYesYesYes
runtime onlyNoNoYes, productionNo

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_URL still 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.

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.