Environment and Configuration
The idea in one sentence
Section titled “The idea in one sentence”Environment variables are server-only by default, and only those prefixed with NEXT_PUBLIC_ are inlined into the client bundle — and only when the access is a static, literal process.env.NEXT_PUBLIC_X, so a secret must never carry that prefix and a dynamic lookup never reaches the browser.
Server-only versus NEXT_PUBLIC_
Section titled “Server-only versus NEXT_PUBLIC_”A plain env var is available only on the server. Prefix it with NEXT_PUBLIC_ and Next.js inlines its value into the JavaScript sent to the browser at build time. That is perfect for a public analytics id and disastrous for an API secret.
// Server Component or route — both work on the serverconst dbUrl = process.env.DATABASE_URL // server-only, safeconst analyticsId = process.env.NEXT_PUBLIC_ANALYTICS_ID // also in the client bundleDATABASE_URL="postgres://user:pass@host:5432/db" # never prefix a secretNEXT_PUBLIC_ANALYTICS_ID="abcdefghijk" # safe to exposeBecause NEXT_PUBLIC_ values are baked in at build time, changing one means you must rebuild — it is not read at runtime in the browser.
NEXT_PUBLIC_ must be a static literal — proven
Section titled “NEXT_PUBLIC_ must be a static literal — proven”Next.js inlines NEXT_PUBLIC_ variables with a plain text substitution during the build: it looks for the exact source pattern process.env.NEXT_PUBLIC_X and replaces it with the literal value. A dynamic lookup — process.env[someVariable] — doesn’t match that pattern, so it is never replaced, and in the browser process.env doesn’t otherwise exist.
Proving both sides in the probe with a Client Component:
// app/page.tsx — 'use client', both variants read the same variable'use client'import { useEffect } from 'react'
function readEnv(name: string) { return process.env[name] // dynamic — the key is a runtime argument}
export default function Page() { useEffect(() => { console.log('STATIC', process.env.NEXT_PUBLIC_ANALYTICS_ID) console.log('DYNAMIC', readEnv('NEXT_PUBLIC_ANALYTICS_ID')) }, []) return <div>env inlining probe</div>}After next build, grepping the emitted client chunk for both call sites shows the static access literally replaced with the value from .env.local, while the dynamic one is still a live (and in the browser, empty) property read:
console.log("STATIC","abcdefghijk123"),console.log("DYNAMIC",i.default.env.NEXT_PUBLIC_ANALYTICS_ID)i.default.env.NEXT_PUBLIC_ANALYTICS_ID is a property access on the client-side process shim at runtime, not a literal — and that shim only exists so the statically detected references resolve; a key computed at runtime was never detected, so it resolves to undefined in the browser. Wrapping the same access in a helper function that takes the key as a parameter is enough to defeat the substitution, even though the helper is always called with the same literal string.
.env files and load order
Section titled “.env files and load order”Next.js loads several .env files and the first match wins. For production the order is .env.production.local, then .env.local, then .env.production, then .env. Keep secrets in .env.local (gitignored) and commit only safe defaults.
.env # committed defaults, safe values only.env.local # gitignored, real secrets.env.development # loaded during next dev.env.production # loaded during next build / next startEnv vars in proxy and instrumentation
Section titled “Env vars in proxy and instrumentation”proxy.ts and instrumentation.ts are both plain Node.js modules that run on the server, so they read process.env the same way any Server Component does — no special casing, and NEXT_PUBLIC_ variables work there too (they’re just never inlined into a client bundle for these files, since neither ships to the browser).
// instrumentation.ts — env vars are available in register() as-isexport function register() { console.log('runtime:', process.env.NEXT_RUNTIME) // 'nodejs' or 'edge'}Proven in the probe: starting the standalone server logs INSTRUMENTATION_REGISTER_CALLED nodejs — register() runs once per server instance, before it accepts traffic, with the full server-side process.env already populated.
Build-time vs runtime config
Section titled “Build-time vs runtime config”NEXT_PUBLIC_ values are frozen the moment next build runs — promoting the same build across environments (or the same Docker image across stages) means those values travel with the image, not with the environment it lands in. For a value that must be read fresh on every request, read a server-only variable inside a component that has opted into dynamic rendering — calling connection() (or reading cookies()/headers()) forces that:
// app/page.tsx — read a server env var at request time, not at build timeimport { connection } from 'next/server'
export default async function Page() { await connection() // cookies, headers, and other request-time APIs also opt into // dynamic rendering, so this is evaluated per request, not baked in const value = process.env.MY_VALUE return <div>{value}</div>}This is exactly why a single Docker image with output: 'standalone' can be promoted through staging and production unchanged: server-only env vars are read fresh from the container’s environment at runtime, while only the NEXT_PUBLIC_ ones are locked in at the build that produced the image.
next.config.ts essentials
Section titled “next.config.ts essentials”next.config.ts is where you configure the framework itself. The four you reach for most: images.remotePatterns (allow external image hosts), redirects, rewrites, and headers.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: 's3.amazonaws.com', pathname: '/my-bucket/**' }, ], }, async redirects() { return [{ source: '/about', destination: '/', permanent: true }] }, async rewrites() { return [{ source: '/docs/:slug', destination: '/help/:slug' }] }, async headers() { return [ { source: '/(.*)', headers: [{ key: 'X-Frame-Options', value: 'DENY' }], }, ] },}
export default nextConfigMental Model
Section titled “Mental Model”Think of next build as a printing press, not a photocopier. Anything marked NEXT_PUBLIC_ and accessed as a literal gets typeset directly into the page — permanent ink, unchangeable after the run. Everything else is a blank you fill in at request time, read from whatever environment the container currently lives in. A dynamic lookup (process.env[key]) is a blank the press doesn’t know to typeset — it isn’t a secret leak, it’s just never filled in for the reader (the browser).
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
NEXT_PUBLIC_ static literal access | Value ships to the browser, zero runtime lookup cost | Frozen at build time — a value change needs a rebuild |
Dynamic process.env[key] access | Looks flexible, reads like a normal object lookup | Never inlined for the client — silently undefined in the browser |
Server-only var read behind connection() | Fresh per request, one image promotable across environments | Opts the route into dynamic rendering — no longer a static shell |
.env.local for secrets | Never committed, overrides .env locally | Must be provisioned separately in every environment (CI, containers) |
Common Misconceptions
Section titled “Common Misconceptions”- Any
process.env.NEXT_PUBLIC_Xreference is automatically safe to reason about. It’s safe from being a secret leak by omission — but the inlining only happens for the exact static form; the same variable accessed dynamically silently returnsundefinedin the browser instead of the value. process.env[key]andprocess.env.KEYbehave identically once bundled. Proven false:next buildonly replaces the literal-property form with the value. The bracket form (or any indirection through a helper function) is invisible to that substitution.- Changing a
NEXT_PUBLIC_value in.env.localand restartingnext startpicks up the new value. It doesn’t —NEXT_PUBLIC_values are inlined duringnext build, not read atnext start. A rebuild is required. - A server-only env var read in a page is automatically “runtime” and always fresh. Only if the route has opted into dynamic rendering (via
connection(),cookies(),headers(), or similar) — a fully static route still evaluates that read once, at build/prerender time.
graph TD A["process.env.SECRET"] --> B["Server only"] C["process.env.NEXT_PUBLIC_ID (literal)"] --> B C --> D["Inlined into client bundle at build time"] E["process.env[dynamicKey]"] --> B E -.->|never inlined| F["undefined in the browser"]
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet touches env vars or config, check for these before trusting it:
- Does a Client Component (or anything shipped to the browser) access
process.envthrough a variable, a computed key, or a helper function instead of the literalprocess.env.NEXT_PUBLIC_X? That access will not be inlined and silently returnsundefinedat runtime. - Does any variable without the
NEXT_PUBLIC_prefix get passed into a Client Component as a prop, or interpolated into client-facing JS? That’s a secret leak through a different door — the prefix rule only protectsprocess.envaccess itself. - Does the code assume changing
.env.localand restartingnext start(without rebuilding) picks up a newNEXT_PUBLIC_value? It won’t — those are inlined atnext build. - Does a page read a server-only env var and assume it’s always fresh per request, without any request-time API (
connection(),cookies(),headers()) forcing dynamic rendering? A static route bakes that read in once. - Does
next.config.tsreferenceprocess.envfor something that should vary per deployment of the same image (likeoutput: 'standalone'self-hosting)? Config values are evaluated at build time too, unless explicitly deferred.
🐛 Spot the bug
'use client'
const PUBLIC_KEYS = {analytics: 'NEXT_PUBLIC_ANALYTICS_ID',region: 'NEXT_PUBLIC_REGION',}
function getPublicEnv(name: keyof typeof PUBLIC_KEYS) {return process.env[PUBLIC_KEYS[name]]}
export function Analytics() {const id = getPublicEnv('analytics')return <span data-analytics-id={id} />}Show the answer
getPublicEnv reads process.env[PUBLIC_KEYS[name]] — a bracket access through two levels of indirection. Even though PUBLIC_KEYS.analytics is always the literal string 'NEXT_PUBLIC_ANALYTICS_ID', Next.js’s build-time substitution only matches the exact source text process.env.NEXT_PUBLIC_ANALYTICS_ID. It never sees that pattern here, so nothing gets inlined — data-analytics-id renders as undefined for every visitor, with no build error or warning. The fix is to reference each NEXT_PUBLIC_ variable directly, as a literal, at every call site that needs it in client code.