Managing multiple .env files in Next.js

Next.js loads up to five env files, in a fixed order, and picks which ones based on NODE_ENV. Most of the confusion around them is not really about precedence though. It comes from the split between values that are baked into a build and values that are read when the server runs, because the two behave completely differently when you change one.

So: the load order, the two rules people get caught by, the NEXT_PUBLIC_ boundary, and how it all maps onto local, preview, staging and production.

The files Next.js loads, in order

Highest precedence first. The first place a variable is found wins, and later files do not overwrite it:

  1. Real environment variables already present in the process. Anything set by your shell, your CI runner or your host beats every file on this list.
  2. .env.$(NODE_ENV).local, for example .env.development.local.
  3. .env.local, with one exception, covered below.
  4. .env.$(NODE_ENV): .env.development, .env.production or .env.test.
  5. .env, the base file, loaded in every environment.

NODE_ENV is set for you by the command you run. next dev gives you development, next build and next start give you production, and test runners typically set test. You do not normally set it yourself, and setting it to something custom is not how you select a deployment environment.

.env.example is not in the list. Nothing loads it. It exists purely as documentation of the shape of your env, which is exactly why it is the file that quietly falls out of date.

The two rules people trip over

.env.local is not loaded when NODE_ENV is test. This is deliberate. Tests should behave the same for everyone who runs them, and .env.local is the file that holds your personal machine’s values. So a variable that works in next dev can be undefined in your test run, with nothing to tell you why. Put test values in .env.test, and commit that file.

A NODE_ENV environment is not a deployment environment. There are three NODE_ENV values and they map to what the process is doing, not to where it is deployed. Staging is a production build. So is a preview deploy, a QA box and a customer demo instance. They all read .env.production, because that is the only NODE_ENV file a production build looks for. Creating .env.staging and expecting Next.js to pick it up does not work. Next.js will not load it, and there will be no error, just missing values. If you want per-deployment values, they need to arrive as real environment variables from the host, or be loaded explicitly by whatever runs your build.

NEXT_PUBLIC_ is a copy, not a reference

A variable prefixed with NEXT_PUBLIC_ gets inlined into the JavaScript bundle at build time. The browser does not look the value up. The value is written into the compiled output as a literal, wherever the code references it statically. Three consequences follow, and each one bites somebody every week.

It is not a secret. Anyone can read it out of the bundle with devtools and no special effort. Publishable keys, analytics IDs and public API base URLs are fine. A Stripe secret key with NEXT_PUBLIC_ in front of it is a published secret, and no amount of .gitignore changes it back.

Changing it needs a rebuild, not a restart. In development the server notices env file changes and reloads, so this is invisible locally. In production the value is already inside a file that was compiled and shipped. Restarting the server changes nothing. You need a new build.

One build cannot serve two environments with different public values. If you build a Docker image on CI and promote it from staging to production, every NEXT_PUBLIC_ value in it is whatever was set on the machine that ran next build. That means either building once per environment, or not putting the differing value in a NEXT_PUBLIC_ variable at all and fetching it from the server at runtime instead. Server-only values are fine in a promoted image, because those are read from the container’s environment when the server runs.

One more detail worth knowing: only static references get inlined. Building a variable name dynamically and looking it up in process.env gives you nothing in client code, because there was no literal for the compiler to replace.

Server-only variables in the App Router

Anything without the prefix is server-only. It is readable in Server Components, Route Handlers, Server Actions, middleware and next.config. It is not readable in a client component, and the failure mode is the awkward part.

A file marked 'use client' gets compiled into the browser bundle. Any server-only process.env read inside it will not have a value there. Worse, the problem usually arrives indirectly: a helper module reads a server-only variable at the top level, a client component imports that helper for one unrelated function, and the whole module is pulled into the client graph. Now it either breaks on undefined or, if you have been careless about what else is in that module, hands something to the browser you did not intend to publish.

The practical rule is to keep env reads in one place and never import that place from client code. A single server-side config module that reads process.env once, validates it, and exports typed values is much easier to reason about than reads scattered across thirty files. Adding import 'server-only' at the top of that module turns an accidental client import into a build error instead of a runtime surprise, which is the whole point.

Local, preview, staging, production

Splitting it by what belongs where:

  • In the repo: .env.example always, and .env if it contains genuine defaults that are the same everywhere and are not secret. .env.development and .env.production can be committed too, as long as they hold shape and non-secret defaults rather than real credentials.
  • Never in the repo: .env.local and the .local variants. That is what the .local suffix means, and the default Next.js .gitignore already excludes them.
  • In the host’s dashboard: every real credential. On Vercel that is project settings, with separate values per environment, which is what makes preview and branch deploys work without a file. vercel env pull writes them back down into .env.local for local work. Self-hosted, it is whatever your process manager, container runtime or secret store injects, and the same rule applies: real values live there, not in a file you push.

For a monorepo, the file has to sit next to the app, not at the workspace root. Next.js loads env files from the directory it runs in, so apps/web/.env.local is read and a repo-root .env.local is not. Workspace task runners add one more thing to watch: if your build cache does not treat the env file or its variables as an input, a cached build can be replayed with the previous values already baked in. Check how your runner declares env inputs before trusting a cache hit.

The traps

  • A value committed in .env.production because it “wasn’t a secret”. Internal hostnames, queue names and bucket URLs are reconnaissance. They also age badly, and a committed file is the one nobody thinks to update.
  • Drift between the host’s dashboard and your local file. Nothing tells you about this. You add a variable on Vercel, never add it locally, and the app works for you and fails in preview, or the reverse. It usually surfaces as a bug someone else cannot reproduce.
  • Expecting the file to be read on the host. A gitignored .env.local never leaves your machine, so it is not part of the deploy at all. Values reach a deployed app because something injected them into the process or inlined them at build time, not because the file travelled with the code.
  • Rebuild versus restart. If you changed a NEXT_PUBLIC_ value and nothing happened, this is why.

Where Dotvault fits

Dotvault is a Mac app (Apple Silicon) for managing env files, and a Next.js project is one of the things it understands out of the box. Point it at your app folder and it shows every env file in that folder side by side, labels each one with an environment type worked out from the filename, and annotates variables it recognises, including anything carrying the NEXT_PUBLIC_ prefix. See /nextjs for what it detects specifically.

The parts that matter for juggling several files: the Compare tab puts every variable across every file in one grid so a value that is wrong in one environment stands out, the Diff tab compares two files key by key and lets you copy keys across, and .env.example sync offers to add new keys to your example file after a save so it stops falling behind. If a file that git can see holds something matching a known secret pattern, you get a warning about it, though the fix is still yours to make. There is more on the git side in keeping .env out of git, and on the naming question in .env vs .env.local.

That check stays quiet on a gitignored .env.local, which is correct: the file is not exposed to git. The one that does not is the client-exposed warning. Put a value under NEXT_PUBLIC_ that can only be a secret, a Stripe or Anthropic secret key, a GitHub or Slack token, an AWS access key id, a webhook signing secret, and the row is flagged whatever git thinks of the file, because the value is inlined into the bundle at build time and gitignoring the file does nothing about what reaches the browser. Publishable keys are left alone, since shipping those to the browser is the entire point of the prefix. What “client-exposed secret” means has the full list.

Two limits to be straight about. A project is a single folder and the scan is not recursive, so a monorepo means adding each app’s folder as its own project (more on that here). And Dotvault does not talk to your host’s dashboard. The drift between your local file and what Vercel has set is not something it can see, so that one still needs checking by hand.

Worth a look if you keep more than two env files per project: www.dotvault.dev.