Environment files in Next.js look simple at first. You add a .env.local, read a value with process.env, and move on. The trouble starts later, when a value works in development but disappears in production, or when a supposedly secret variable ends up in the browser bundle, or when a staging deployment still points at the wrong API.
Most of those mistakes come from not knowing how Next.js actually handles environment files. The good news is that the rules are consistent: the framework has a defined set of file names, a predictable loading order, and a clear rule for which variables are exposed to client-side code.
Once those rules are understood, environment setup becomes much less mysterious. The real work is not memorizing file names. It is understanding which source wins, when values are read, and which values belong on the server instead of the browser.
The file names Next.js recognizes
Next.js follows a small set of conventional environment file names: .env, .env.local, .env.development, .env.production, .env.test, plus their *.local variants for each mode.
1 Base file
.env is the broadest default layer and works well for shared non-secret defaults.
2 Local override
.env.local is meant for machine-specific overrides and local secrets that should not be committed.
3 Mode-specific files
.env.development, .env.production, and .env.test let values vary by framework mode.
.env
.env.local
.env.development
.env.production
If a file does not match the expected naming scheme, Next.js does not include it in the normal loading process. That is why inventing custom names often creates confusion instead of clarity.
What the files are usually for
The base .env file is a reasonable place for safe defaults shared across environments, while .env.local is best reserved for developer-specific secrets and machine-specific overrides.
# .env
APP_NAME=mo-app
FEATURE_CONTACT_FORM=true
# .env.local
DATABASE_URL=postgres://localhost:5432/mo_app_dev
SESSION_SECRET=local-dev-secret
# .env.development
API_BASE_URL=http://localhost:4000
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# .env.production
API_BASE_URL=https://api.example.com
NEXT_PUBLIC_SITE_URL=https://example.com
That split keeps shared defaults stable while still allowing local overrides and production-specific values to exist without stepping on each other unnecessarily.
Load order is the real rule
The most important behavior is precedence. When the same variable appears in more than one place, the first matching source in Next.js priority order wins. The useful mental model is this: the framework checks process-level variables first, then the more specific env files, and only later falls back to the broad defaults.
| Priority | Source |
|---|---|
| 1 | Existing process.env |
| 2 | .env.$(NODE_ENV).local |
| 3 | .env.local |
| 4 | .env.$(NODE_ENV) |
| 5 | .env |
So if API_BASE_URL exists in both .env and .env.local, the local file wins. If your deployment platform injects the variable directly into the process environment, that value wins before the files are even considered.
A concrete precedence example
# .env
API_BASE_URL=https://base.example.com
# .env.development
API_BASE_URL=http://localhost:4000
# .env.local
API_BASE_URL=http://127.0.0.1:5000
During next dev, the resolved value will be http://127.0.0.1:5000 because .env.local outranks .env.development, and .env.development outranks .env.
If the deployment platform later injects API_BASE_URL=https://staging-api.example.com directly, that process-level value becomes the effective value instead.
Development, production, and test load different files
The active framework mode changes which env-specific files Next.js uses. next dev reads development-mode files, while next build and next start use production-mode files. Test mode is separate again when the test runner sets it correctly.
What reaches the browser
This is the security boundary that matters most. In Next.js, only variables whose names start with NEXT_PUBLIC_ are exposed to client-side code. Everything else is intended to remain server-only.
DATABASE_URL=postgres://db.internal/my_app
JWT_SECRET=super-secret-value
NEXT_PUBLIC_SITE_URL=https://example.com
NEXT_PUBLIC_SUPPORT_EMAIL=support@example.com
Server code can read all of these values. Client components should only receive the two NEXT_PUBLIC_ variables. If a browser-facing feature seems to require a secret, that usually means the logic belongs on the server instead.
A server and client example
export const serverConfig = {
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
apiBaseUrl: process.env.API_BASE_URL,
};
'use client';
export function FooterMeta() {
return (
<p>
Running on {process.env.NEXT_PUBLIC_SITE_URL}
</p>
);
}
Trying to read process.env.JWT_SECRET from a client component is not a valid design. If the browser needs some derived information, expose only that derived value through a server endpoint or a safe public variable.
Variable expansion is supported
Next.js also supports referencing one environment variable from another. That can be useful when several values share the same host or base path and you want to avoid duplication across env files.
HOST=example.com
NEXT_PUBLIC_SITE_URL=https://$HOST
PRICE_LABEL=$25 plan
The first line expands the host reference. The last line shows how to escape a literal dollar sign instead of triggering expansion.
Build-time behavior is where bugs appear
One of the most important Next.js details is that many environment values, especially public values used in client bundles, are resolved at build time. If you build with one set of public values and later promote that exact artifact to another environment, the browser bundle can still contain the old values.
1 Build separately when needed
If staging and production expose different public values, separate builds are often the safer path.
2 Prefer server-side decisions
If the browser does not truly need a value, keep it on the server side and return only derived data.
3 Do not assume promotion is free
A build artifact created with staging public values is not automatically safe to promote unchanged to production.
Centralize environment access
Even when Next.js loads your files correctly, the codebase can still become messy if every module reads process.env directly. A central env module keeps validation and naming rules in one place.
const required = (value: string | undefined, name: string) => {
if (!value) {
throw new Error('Missing environment variable: ' + name);
}
return value;
};
export const env = {
appName: process.env.APP_NAME ?? 'mo-app',
apiBaseUrl: required(process.env.API_BASE_URL, 'API_BASE_URL'),
siteUrl: required(process.env.NEXT_PUBLIC_SITE_URL, 'NEXT_PUBLIC_SITE_URL'),
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL ?? 'support@example.com',
};
That pattern validates required variables early, makes the rest of the codebase easier to read, and prevents environment handling from being scattered across pages, route handlers, helpers, and client components.
Route Handlers are a good server boundary
Route Handlers are a good place to consume server-only environment values because they run on the server boundary. The client receives the API response, not the raw secret or internal configuration value.
import { NextResponse } from 'next/server';
export async function GET() {
const apiBaseUrl = process.env.API_BASE_URL;
const response = await fetch(apiBaseUrl + '/health');
const data = await response.json();
return NextResponse.json(data);
}
What you should usually commit
In many teams, a good default is to commit .env only when it contains safe shared defaults, commit mode-specific files only when they contain safe examples or non-secret defaults, and avoid committing .env.local or other secret local overrides.
The exact policy depends on the project, but the goal stays the same: secrets belong in uncommitted local files or deployment platform settings, not in source control.
Bottom line
Next.js environment files are not complicated, but they are precise. The framework recognizes specific file names, loads them in a clear priority order, and only exposes NEXT_PUBLIC_ variables to browser code.
Once you internalize those rules, configuration becomes much easier to reason about. Local overrides stay local, secrets stay server-side, public values are exposed intentionally, and deployment bugs become much easier to diagnose before they reach production.