Next.js Environment Variables Guide (2026)
Next.js env vars: .env load order, NEXT_PUBLIC_ inlining, server-only secrets, Docker runtime config, Vercel and Cloudflare setup, and what to do after a leaked API key.

If you searched nextjs environment variables or next js environment variables, you probably hit the same wall: values work in next dev, disappear in the client, or stay frozen after you change production config. The official docs are clear once you know the split between build-time inlining (NEXT_PUBLIC_) and server-only process.env, but most threads mix Docker, Vercel, and "why is my key undefined in the browser?"
This guide is a production-oriented walkthrough for App Router teams: .env load order, what to commit, NEXT_PUBLIC_ behavior, where server code can read secrets, runtime env vars with Docker and standalone output, Vercel and Cloudflare, monorepo env with Turborepo, typed validation (without repeating our Envin deep dive), and what to do if an API key leaked in .env. For auth and CSP around secrets, pair this with the Next.js security checklist.
Short answer
Next.js loads .env* files into process.env on the server. Variables prefixed with NEXT_PUBLIC_ are inlined into the client JavaScript at next build time and do not update when you change hosting env later. Everything else stays server-only (Server Components after opting into dynamic rendering, Route Handlers, Server Actions, and your Node or edge runtime). For build once, deploy many Docker images, treat NEXT_PUBLIC_* as build args and pass server secrets at container runtime. Validate env at startup with a schema (Envin in TurboStarter) so missing secrets fail CI, not Friday night prod.
Quotable definition: A Next.js environment variable is a key/value pair loaded from .env* files or the host OS into process.env. Only keys prefixed with NEXT_PUBLIC_ are exposed to browser bundles, and those values are fixed when you run next build.
Which .env files exist and what to commit
Next.js (and tools like @next/env) load variables from the project root, not from /src alone. In a monorepo web app, that usually means the app package root (for example apps/web) plus shared root .env files TurboStarter documents in environment variables.
| File | Typical use | Commit to Git? |
|---|---|---|
.env | Defaults shared across machines | Often yes (non-secret defaults only) |
.env.development / .env.production | Environment-specific defaults | Often yes (no secrets) |
.env.local | Developer overrides and local secrets | Never (gitignored) |
.env.development.local / .env.production.local | Per-env local overrides | Never |
.env.test | Test runner defaults | Yes (no secrets); .env.test.local stays ignored |
TurboStarter keeps shared keys at the repo root and app-specific keys under apps/web, with secrets in .env.local for local work and in the host secret store for production. That layout matches how teams actually ship: one backend, multiple clients (web, mobile, extension).
Official reference: Environment Variable Load Order.
Environment variable load order in Next.js
When Next.js resolves process.env.MY_VAR, it stops at the first hit in this order (docs):
process.env(already set by the shell, Docker, Vercel, Cloudflare, and so on).env.$(NODE_ENV).local(not loaded whenNODE_ENVistest).env.local(not loaded whenNODE_ENVistest).env.$(NODE_ENV)(for example.env.development,.env.production).env
Example: with NODE_ENV=development, a variable in .env.development.local wins over the same key in .env.
Good to know from the docs:
- Allowed
NODE_ENVvalues aredevelopment,production, andtest. - If
NODE_ENVis unset,next devsetsdevelopment; other commands default toproduction. .envsupports multiline values and$OTHER_VARexpansion (escape literal$as\$).
For scripts outside Next (Drizzle CLI, Jest, Playwright), use loadEnvConfig from @next/env so test and migration tools see the same files as the app (docs).
NEXT_PUBLIC_ and build-time inlining (not runtime)
This is the behavior behind most nextjs environment variables client side confusion.
Non-NEXT_PUBLIC_ variables are available only in the Node.js / edge server runtime. They are not sent to the browser bundle.
To expose a value to client code, prefix it with NEXT_PUBLIC_. Next.js replaces every static reference like process.env.NEXT_PUBLIC_ANALYTICS_ID with the string literal present at next build time (docs).
Implications:
- Changing
NEXT_PUBLIC_*in Vercel or Docker after build does not update client bundles until you rebuild. - Promoting one Docker image from staging to production without rebuilding freezes public env in the staging values unless you planned for it.
- Dynamic lookups are not inlined, for example
process.env[variableName]orconst env = process.env; env.NEXT_PUBLIC_X. Do not rely on those patterns for client config.
// Inlined at build time when referenced directly
const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
// NOT inlined — do not use for client secrets or feature flags you expect to toggle without rebuild
const name = "NEXT_PUBLIC_POSTHOG_KEY";
const dynamic = process.env[name];TurboStarter maps marketing and analytics keys under NEXT_PUBLIC_ in apps/web/env.config.ts and keeps billing, database, and auth secrets server-side. See Secrets and environment for naming rules.
Server-only access: where secrets are safe
Use server-only modules for anything that must not reach the client. Next.js recommends keeping process.env reads in server code paths (environment variables guide).
| Surface | Server-only env? | Notes |
|---|---|---|
| Server Components | Yes (default) | Can read secrets when rendered on the server |
Route Handlers (route.ts) | Yes | Webhooks, mobile API, auth callbacks |
| Server Actions | Yes | Still treat as public endpoints; auth inside the action |
| Client Components | No for secrets | Only NEXT_PUBLIC_* inlined at build |
| Proxy / middleware | Yes, but limited | Runs on the edge; only env available in that runtime |
Runtime server values (App Router): For server-only variables, Next.js documents that you can read process.env during dynamic rendering after calling connection() from next/server, so the value is evaluated at request time rather than at static build time (runtime environment variables). That pattern helps Docker and standalone deployments: one image, different DATABASE_URL per environment, without rebaking the client bundle.
import { connection } from "next/server";
export default async function AdminBanner() {
await connection();
const region = process.env.DEPLOY_REGION ?? "unknown";
return <p className="text-muted-foreground text-sm">Region: {region}</p>;
}Proxy / middleware note: In Next.js 16, edge proxy runs with its own env scope. Do not treat proxy as your only auth gate (security guide). Secrets needed in proxy should be configured on the edge host (Vercel, Cloudflare) explicitly.
Docker, standalone output, and runtime env vars
nextjs docker environment variables searches usually mean: "I passed -e DATABASE_URL=... but the client still cannot see my API key." Server secrets should work at container start; client public vars need to be present during next build inside the image build stage.
TurboStarter's Docker self-host guide sets output: "standalone" so the runtime image stays small. Typical pattern:
- Build stage: pass
NEXT_PUBLIC_*as build args if they must match that image's environment. - Run stage: pass server secrets with
docker run -eor your orchestrator's secret store. - Do not commit
.env.productionwith real keys into the image layers; use.dockerignore.
# Build-time public config (example)
ARG NEXT_PUBLIC_URL
ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
RUN pnpm exec turbo build --filter=webdocker run -p 3000:3000 \
-e DATABASE_URL="postgresql://..." \
-e BETTER_AUTH_SECRET="..." \
your-image:tagIf NEXT_PUBLIC_ values differ per environment, you either rebuild per environment or avoid putting changeable config in NEXT_PUBLIC_ (serve a small runtime config from a Route Handler instead). There is no magic "reload .env in the browser" switch.
When env vars look undefined inside Docker but fine locally, check:
- File copied into image?
.env.localis usually not in the image by design. - Variable set on
docker run, not only in composeenv_filewith wrong service name? - Client vs server: browser code only sees
NEXT_PUBLIC_*from the build that produced the bundle.
Vercel and Cloudflare env setup
Vercel environment variables nextjs is mostly about scoping: Production, Preview, and Development in the project dashboard, plus pulling the same names into local dev with vercel env pull. Server vars are injected at runtime; NEXT_PUBLIC_* are embedded at build time on Vercel's builders (Vercel env docs).
TurboStarter's default path is documented under Deploy to Vercel. Keep TURBO_ENV_MODE=loose in CI if you use Turbo on Vercel so undeclared env keys are not stripped before Next reads them (see repo .env comments).
Cloudflare Workers add a split between build-time and Worker runtime secrets. TurboStarter's Cloudflare deploy guide walks through Wrangler vars, .dev.vars for preview, and dashboard secrets for production. Hyperdrive and R2 bindings are separate from plain string env, but DATABASE_URL-class secrets still belong in Worker secrets, not in client bundles.
For Edge Kit on TanStack Start, see Edge Kit and Edge env docs when you outgrow Core Next.js hosting.
Typed validation: fail in CI, not in production
Manual process.env.FOO ?? "" checks do not scale across monorepos. TurboStarter validates env with Envin schemas in apps/web/env.config.ts and shared packages so next build fails when required keys are missing.
We already published a full product walkthrough: Envin type-safe environment variable validation. Use that post for CLI previews, presets, and client/server separation details. Here, the rule is simple: one schema per deployable, strict at build time, and never mark secrets as client in Envin.
Monorepo and Turborepo env handling
In a Turborepo, env vars affect cache keys. If a task reads DATABASE_URL but Turbo does not know, remote cache can serve a build from another environment's config.
TurboStarter's turbo.json declares globalEnv (for example DATABASE_URL, PRODUCT_NAME) and task-level env / passThroughEnv on build and dev. Read the full mental model in Turborepo: the ultimate guide and Advanced Turbo CLI env caching.
Practical monorepo rules:
- Put shared non-secret defaults in the root
.env. - Put Next-specific
NEXT_PUBLIC_*mapping inapps/web/.env.local(see docs example). - List any env var that changes build output in
turbo.jsonenvfor that task. - Use
pnpm with-env(or your wrapper) so CLI commands load the same files as Next.
What to do if you leaked an API key in .env
Search traffic to TurboStarter docs shows real queries like exposed Lemon Squeezy, Groq, GitHub (ghp_), and Serper keys in .env files. Treat every leak as incident response, not a git revert.
Revoke or roll the key in the provider dashboard (Stripe, OpenAI, GitHub PAT, and so on). Assume the old value is public the moment it hit a remote, a screenshot, or a fork.
Delete the line from tracked files. Move local dev secrets to .env.local only. Update CI and hosting secret stores with the new value.
If the key ever lived in a committed file, git revert is not enough. Use git filter-repo or BFG Repo-Cleaner, then force-push after coordinating with your team. GitHub may still retain objects for a window; rotation remains mandatory.
Turn on GitHub secret scanning and push protection for organization repos. Add local and CI guards with Gitleaks or TruffleHog in pre-commit and GitHub Actions.
Check provider audit logs for usage after the leak window. Redeploy so runtime env picks up rotated server secrets. Rebuild if a NEXT_PUBLIC_ value was compromised (treat it as public forever).
After RCE-class Next.js advisories, the framework team has recommended rotating application secrets when production was unpatched (example: React2Shell guidance). The same discipline applies to leaked .env files.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
undefined in Client Component | Var is server-only or dynamic lookup | Use NEXT_PUBLIC_ and static process.env.NEXT_PUBLIC_X, then rebuild |
undefined in Docker for server var | Not passed at docker run / wrong service | Set runtime -e or compose secrets; confirm standalone server started |
| Changed Vercel env, client unchanged | NEXT_PUBLIC_ frozen at build | Redeploy with rebuild; avoid public prefix for toggles |
next dev not picking up edits | Server already started | Restart dev server; .env.local overrides require restart |
| Works locally, fails in CI | Missing CI secret or strict Turbo env | Add keys to GitHub Actions secrets; declare in turbo.json |
| nextjs 14 environment variables undefined on old posts | Mixed Pages/App Router advice | Confirm App Router docs and Next 15+ async config patterns |
FAQ
Not directly. Only NEXT_PUBLIC_* values inlined at build time are available in client bundles. Fetch non-secret config from a Route Handler or pass props from a Server Component parent.
After you change .env* files, restart next dev. Production Node processes do not hot-reload env files; redeploy or restart the container.
Build-time: values read while next build runs, including all NEXT_PUBLIC_* in client bundles. Runtime: server-only vars read when handling requests (especially in dynamic Server Components after connection()). Client public config does not update at runtime without a rebuild or a custom API.
Shared keys live in the root .env; each app package can override. Only the Next app uses NEXT_PUBLIC_ for browser exposure. See environment variables and mobile/extension docs for app-specific files.
No. It grants database access. Keep it server-only and validate with Envin. Same rule for billing keys, auth secrets, and webhook signing secrets (secrets doc).
Vercel injects env into the build and serverless/edge runtime. Names match what Next expects in .env. Remember NEXT_PUBLIC_* still requires a fresh build when values change.
Revoke the token or key immediately, rotate, purge history if committed, enable secret scanning, and audit provider logs. See the leak response steps above.
Bottom line
Nextjs environment variables are two systems: NEXT_PUBLIC_ baked into JS at build, and server-only process.env read during requests or server startup. Respect load order, keep secrets out of Git, validate with Envin, align Turborepo cache env with turbo.json, and treat Docker plus Vercel plus Cloudflare as runtime secret injection, not a second .env in the browser.
If you want a production-ready SaaS layout (web, mobile, extension, and AI from one backend) with env schemas already wired, see TurboStarter pricing and the best SaaS starter kit 2026 comparison, then copy the environment variable docs into your first deploy checklist.
Next.js API Router: Route Handlers guide
Next.js API Router explained: App Router route.ts handlers, vs Pages API and Server Actions, plus when to mount tRPC.
30x faster linting and formatting - ESLint and Prettier replaced with OXC
TurboStarter replaces ESLint and Prettier with OXC's Rust toolchain, cutting lint and format times from minutes to seconds across the whole monorepo.



