10+ AI SaaS templates for web & mobile
home

Next.js Environment Variables Guide (2026)

·13 min read

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.

FileTypical useCommit to Git?
.envDefaults shared across machinesOften yes (non-secret defaults only)
.env.development / .env.productionEnvironment-specific defaultsOften yes (no secrets)
.env.localDeveloper overrides and local secretsNever (gitignored)
.env.development.local / .env.production.localPer-env local overridesNever
.env.testTest runner defaultsYes (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):

  1. process.env (already set by the shell, Docker, Vercel, Cloudflare, and so on)
  2. .env.$(NODE_ENV).local (not loaded when NODE_ENV is test)
  3. .env.local (not loaded when NODE_ENV is test)
  4. .env.$(NODE_ENV) (for example .env.development, .env.production)
  5. .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_ENV values are development, production, and test.
  • If NODE_ENV is unset, next dev sets development; other commands default to production.
  • .env supports multiline values and $OTHER_VAR expansion (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] or const 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).

SurfaceServer-only env?Notes
Server ComponentsYes (default)Can read secrets when rendered on the server
Route Handlers (route.ts)YesWebhooks, mobile API, auth callbacks
Server ActionsYesStill treat as public endpoints; auth inside the action
Client ComponentsNo for secretsOnly NEXT_PUBLIC_* inlined at build
Proxy / middlewareYes, but limitedRuns 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:

  1. Build stage: pass NEXT_PUBLIC_* as build args if they must match that image's environment.
  2. Run stage: pass server secrets with docker run -e or your orchestrator's secret store.
  3. Do not commit .env.production with 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=web
docker run -p 3000:3000 \
  -e DATABASE_URL="postgresql://..." \
  -e BETTER_AUTH_SECRET="..." \
  your-image:tag

If 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.local is usually not in the image by design.
  • Variable set on docker run, not only in compose env_file with 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 in apps/web/.env.local (see docs example).
  • List any env var that changes build output in turbo.json env for 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

SymptomLikely causeFix
undefined in Client ComponentVar is server-only or dynamic lookupUse NEXT_PUBLIC_ and static process.env.NEXT_PUBLIC_X, then rebuild
undefined in Docker for server varNot passed at docker run / wrong serviceSet runtime -e or compose secrets; confirm standalone server started
Changed Vercel env, client unchangedNEXT_PUBLIC_ frozen at buildRedeploy with rebuild; avoid public prefix for toggles
next dev not picking up editsServer already startedRestart dev server; .env.local overrides require restart
Works locally, fails in CIMissing CI secret or strict Turbo envAdd keys to GitHub Actions secrets; declare in turbo.json
nextjs 14 environment variables undefined on old postsMixed Pages/App Router adviceConfirm App Router docs and Next 15+ async config patterns

FAQ

Sounds good?Now let's make it real. In minutes.
Try TurboStarter

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.

world map
Community

Connect with like-minded people

Join our community to get feedback, support, and grow together with 1,000+ builders on board, let's ship it!

Join us

Ship your startup everywhere. In minutes.

Don't burn tokens on setup and start building features on day one.

Get TurboStarter