
Nextjs Environment variables
Every non-trivial application needs a way to keep secrets, connection strings, and per-environment settings out of the codebase. A database password shouldn't live in a file that gets committed to Git, and an API base URL that points at localhost in development needs to point somewhere else entirely in production. Environment variables are the standard mechanism for this, and Next.js has first-class, built-in support for them — no extra package required for the basics.
What trips people up isn't the mechanism itself, though. It's the boundary between server and browser. Next.js runs your code in two places, and by default an environment variable only exists on one side of that boundary. Get that wrong, and you either leak a secret to every visitor's browser, or you reference undefined in a component that was supposed to have a value. This guide walks through how Next.js loads environment variables, how to deliberately expose one to the client when you need to, and the gotchas that catch even experienced developers.
Why This Deserves Its Own Guide
In a plain Node.js script, environment variables are simple: you set them in your shell or a .env file, read them with process.env.SOMETHING, and that's the whole story. Next.js complicates this in a useful way — because your code doesn't run in just one place.
A Server Component, a Route Handler, and next.config.js all execute in Node.js (or the Edge runtime), where the full process.env is available. A Client Component, on the other hand, is bundled into JavaScript that ships to the browser, and the browser has no concept of process.env at all. If you reference a plain environment variable inside client code, Next.js won't magically wire it up — you have to explicitly tell the build process to bake that value into the bundle.
This dual-environment model is also why Next.js ships its own environment-loading logic instead of relying purely on something like dotenv. It needs to know, at build time, which variables are meant for the server and which are meant to be inlined into client-facing JavaScript, and it needs to load the right files for the right environment (development, production, or test) automatically.
Loading Environment Variables from .env Files
Next.js reads environment variables out of .env* files in your project root and loads them into process.env automatically — you don't need dotenv or any other package for this.
# .env
DB_HOST=localhost
DB_USER=myuser
DB_PASS=mypassword
Once that file exists, any server-side code can read those values directly:
// app/api/route.js
export async function GET() {
const db = await myDB.connect({
host: process.env.DB_HOST,
username: process.env.DB_USER,
password: process.env.DB_PASS,
});
// ...
}
A few details about .env file handling that are easy to miss:
Multiline values are supported. If you need to store something like an RSA private key, you can either use literal line breaks inside double quotes, or escape them with \n:
# .env
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
...
Kh9NV...
...
-----END DSA PRIVATE KEY-----"
# or, equivalently
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nKh9NV...\n-----END DSA PRIVATE KEY-----\n"
Variable expansion works. You can reference one variable from another using a $ prefix, which is handy for composing URLs from smaller pieces:
# .env
TWITTER_USER=nextjs
TWITTER_URL=https://x.com/$TWITTER_USER
Here, process.env.TWITTER_URL resolves to https://x.com/nextjs. If you actually need a literal $ in a value — say, a password that happens to contain one — escape it as \$ so Next.js doesn't try to expand it as a reference.
The /src directory is not where .env files go. If your project uses a src/ folder for application code, your .env* files still belong in the project root, one level up from src/. Next.js will not look inside src/ for them. This is a small thing, but it's a common source of "why isn't this variable loading" confusion for anyone who assumes environment config follows the same directory as the code that consumes it.
.gitignore your .env files. create-next-app sets this up for you by default, but if you're retrofitting an existing project, double-check that .env, .env.local, and friends are excluded from version control. You almost never want secrets committed to a repository, even a private one — repos get forked, mirrored, and occasionally made public by accident.
Exposing Variables to the Browser with NEXT_PUBLIC_
By default, every environment variable you define is server-only. The browser genuinely cannot see it — there's no process.env object floating around in client-side JavaScript for Next.js to populate at runtime the way there is on the server.
To make a value available in the browser, prefix it with NEXT_PUBLIC_:
# .env
NEXT_PUBLIC_ANALYTICS_ID=abcdefghijk
When you run next build, Next.js finds every reference to process.env.NEXT_PUBLIC_ANALYTICS_ID in your code and replaces it with the literal string "abcdefghijk", directly in the JavaScript bundle. There's no runtime lookup happening in the browser — the value is baked in at compile time.
// pages/index.js
import setupAnalyticsService from "../lib/my-analytics-service";
// This gets transformed at build time into:
// setupAnalyticsService('abcdefghijk')
setupAnalyticsService(process.env.NEXT_PUBLIC_ANALYTICS_ID);
function HomePage() {
return <h1>Hello World</h1>;
}
export default HomePage;
This static-replacement approach has an important consequence that the docs mention but that's worth stating plainly: it only works for literal, statically-analyzable references. The bundler is doing a find-and-replace on the exact string process.env.NEXT_PUBLIC_ANALYTICS_ID as it appears in your source. If you construct the property name dynamically, the replacement never happens, and you'll get undefined in the browser with no error to tell you why:
// This will NOT be inlined — the variable name isn't known until runtime
const varName = "NEXT_PUBLIC_ANALYTICS_ID";
setupAnalyticsService(process.env[varName]);
// This will NOT be inlined either, for the same reason
const env = process.env;
setupAnalyticsService(env.NEXT_PUBLIC_ANALYTICS_ID);
I've debugged this exact failure mode more than once: someone refactors a bunch of process.env.X references into a small config object or a helper function that does process.env[key], everything still type-checks fine, and then production silently starts sending undefined to a third-party SDK. If a NEXT_PUBLIC_ variable is coming back empty in the browser, the very first thing to check is whether it's being referenced as a literal, unbroken process.env.NEXT_PUBLIC_SOMETHING expression somewhere the bundler can see it.
The Baked-In Value Problem
Because NEXT_PUBLIC_ variables are inlined at build time, your running application will never see a new value for one of these variables without a rebuild — even if you change the environment variable on your server and restart the process.
This matters more than it sounds like it should, especially in deployment setups that promote a single build artifact across environments. If your pipeline builds one Docker image and then deploys that same image to staging and production, any NEXT_PUBLIC_ values baked in during the build are frozen for the lifetime of that image. Changing the environment variable on the production host does nothing — the value was already compiled into the JavaScript.
If you need a value that genuinely differs by environment but is only knowable at runtime (rather than build time), the fix isn't to fight the inlining behavior. It's to stop using NEXT_PUBLIC_ for that value and instead expose it through an API route or an inline script tag that reads server-side process.env at request time and hands it to the client that way. That keeps the value dynamic instead of frozen into the bundle.
Runtime Environment Variables on the Server
Server-side (non-NEXT_PUBLIC_) variables don't have the baking-in problem, but there's a related nuance worth understanding: Next.js can prerender pages, and a prerendered page's output is generated once and reused for every subsequent request until it's revalidated. If you read process.env.MY_VALUE inside a component that gets statically prerendered, you're reading it at build/prerender time, not at request time.
If you actually need the environment variable evaluated fresh on every request — the same "one image, many environments" pattern discussed above, but for server-only secrets — you opt the component into dynamic rendering first:
// app/page.tsx
import { connection } from "next/server";
export default async function Component() {
await connection();
// cookies, headers, and other request-time APIs
// will also opt this route into dynamic rendering,
// meaning this env variable is evaluated at request time
const value = process.env.MY_VALUE;
// ...
}
Calling connection() (or reading cookies(), headers(), or any other request-scoped API) tells Next.js this component depends on the incoming request and can't be safely cached as static output. Once that's true, process.env.MY_VALUE gets re-evaluated on every request rather than baked into a static render — which is exactly what you want for a single Docker image promoted through multiple environments with different runtime secrets.
It's worth being precise about what this buys you versus what NEXT_PUBLIC_ buys you: NEXT_PUBLIC_ solves "how do I get a value into the browser at all." Runtime evaluation via connection() solves "how do I make sure a server-side value reflects the environment this specific server process is running in, rather than the environment the code was built in." They're solving different problems, and you can need both at once — a value that's public to the client but also correctly reflects the runtime environment (in which case you'd read it dynamically on the server and pass it down as a prop, rather than inlining it with NEXT_PUBLIC_).
Loading Environment Variables Outside of Next.js
Sometimes you need the same .env* values inside a tool that isn't Next.js itself — an ORM config file, a standalone migration script, or a test runner's global setup. For these cases, Next.js exposes the loading logic it uses internally as a standalone package, @next/env:
npm install @next/env
// envConfig.ts
import { loadEnvConfig } from "@next/env";
const projectDir = process.cwd();
loadEnvConfig(projectDir);
Then import that file wherever you need the variables loaded before anything else runs:
// orm.config.ts
import "./envConfig.ts";
export default defineConfig({
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
});
This matters more than it might seem, because it's tempting to just install dotenv for this instead. The problem is that dotenv alone doesn't replicate Next.js's file precedence rules (more on that below) or its $VARIABLE expansion. If your ORM config loads .env with plain dotenv while your app loads the same values through Next.js's own logic, you can end up with subtly different values between the two — for example, if you have both .env and .env.local defining DATABASE_URL differently, and only one of your two loaders respects the override. Using @next/env in both places keeps the loading behavior identical everywhere.
A Third Environment: test
Most explanations of .env.development and .env.production stop at the obvious two environments, but Next.js recognizes a third: test. If you set NODE_ENV=test, Next.js will load .env.test instead of .env.development or .env.production — useful for test runners like Jest or Cypress that need deterministic values rather than whatever happens to be in your local development config.
The one meaningful difference from the other two environments: .env.local is never loaded when NODE_ENV is test. This is deliberate — .env.local exists specifically to hold personal, machine-specific overrides that shouldn't be shared, and test runs are supposed to produce the same result for every developer and every CI runner regardless of what's sitting in someone's local override file. If your tests are behaving differently on your machine than in CI, and you have a .env.local file, that inconsistency is probably not coming from .env.local — but it's worth remembering that anything you were relying on there won't apply during test runs at all.
As with the other environment files, .env.test is meant to be committed to your repository (it holds safe, shared test defaults), while .env.test.local should not be (same override-with-secrets pattern as .env.local).
You usually don't need to set NODE_ENV=test by hand — testing tools like Jest and Vitest set it for you. But if you're writing a custom global test setup and want the same loading behavior Next.js uses internally, @next/env's loadEnvConfig works here too:
// jest.global-setup.js
import { loadEnvConfig } from "@next/env";
export default async () => {
const projectDir = process.cwd();
loadEnvConfig(projectDir);
};
Environment Variable Load Order
When more than one .env* file defines the same variable, Next.js resolves the conflict using a fixed precedence order, checked top to bottom, stopping at the first match:
process.env(variables already set in the actual shell/OS environment).env.$(NODE_ENV).local.env.local(skipped entirely whenNODE_ENVistest).env.$(NODE_ENV).env
So if NODE_ENV=development and DATABASE_URL is defined in both .env.development.local and plain .env, the value from .env.development.local wins — it's higher in the precedence list. The practical convention this implies:
| File | Committed to Git? | Typical use |
|---|---|---|
.env | Yes | Shared defaults across all environments |
.env.development | Yes | Shared defaults specific to development |
.env.production | Yes | Shared defaults specific to production |
.env.test | Yes | Shared defaults specific to testing |
.env.local | No | Personal overrides, secrets, per-machine values |
.env.development.local | No | Personal dev-only overrides |
.env.production.local | No | Personal production-only overrides (rare) |
The NODE_ENV value itself is one of exactly three strings that Next.js recognizes for this purpose: production, development, and test. If you don't set it explicitly, Next.js assigns development automatically when you run next dev, and production for every other command (including next build and next start). You don't usually need to set NODE_ENV by hand — Next.js and your test runner both manage it — but if you're debugging a "which file is actually being loaded" issue, checking process.env.NODE_ENV first tells you which branch of this precedence table applies.
Common Mistakes I See Repeatedly
Assuming a missing NEXT_PUBLIC_ prefix will throw an error. It won't. Referencing a server-only variable from client code doesn't fail loudly — it just silently evaluates to undefined in the browser, because the bundler has nothing to inline. This makes the mistake easy to miss in development if the resulting undefined doesn't immediately break anything visible, and it can ship to production before anyone notices.
Expecting a NEXT_PUBLIC_ change to take effect without a rebuild. Editing .env.production and restarting the server does nothing for already-built NEXT_PUBLIC_ values — they were compiled into static JavaScript during next build. You have to rebuild.
Putting .env* files inside src/. They belong in the project root regardless of where your application code lives.
Committing a .env.local file "just this once." Once a secret is in Git history, rotating it is the only real fix — deleting the file in a later commit doesn't remove it from history. Double-check .gitignore before your first commit on a new project, not after.
Building a helper function that reads process.env dynamically for a client-exposed variable. As covered above, dynamic property access breaks the static replacement Next.js relies on for NEXT_PUBLIC_ variables. Reference the full, literal variable name directly wherever it's used in client code.
Key Takeaways
| Question | Answer |
|---|---|
| Where do server-side variables come from? | .env* files in the project root, loaded automatically into process.env |
| How do I expose a value to the browser? | Prefix it with NEXT_PUBLIC_ |
When is a NEXT_PUBLIC_ value evaluated? | At next build time — it's inlined into the JS bundle, not read at runtime |
| How do I get a fresh, request-time value on the server? | Read it inside a component that's opted into dynamic rendering (e.g. via connection()) |
How do I load .env* files outside of Next.js itself? | Use loadEnvConfig from the @next/env package |
What's special about the test environment? | It ignores .env.local, so test runs stay consistent across machines |
| Which file wins if the same variable is defined twice? | The one higher in the precedence list: .local variants beat non-.local, and environment-specific files beat plain .env |
Environment variables in Next.js aren't complicated once you internalize the one governing idea: server-only by default, browser-visible only if you explicitly say so with NEXT_PUBLIC_, and baked in at build time rather than read live once that prefix is involved. Everything else — the file precedence, the test environment, the @next/env package — is really just tooling built around making that one boundary easy to work with correctly.


