Best Practices for Managing Environment Variables in Next.js

Environment variables are one of those topics that look dull right up until they break a deployment, leak a secret, or quietly point production traffic at the wrong service. Next.js gives us a decent set of defaults, but it also makes one distinction easy to underestimate: some values should stay on the server, whilst anything prefixed with NEXT_PUBLIC_ is intended for the browser and gets baked into the client bundle at build time.
That line matters much more than people sometimes think. The variable itself is just a string. The real work is deciding where it may be read, when it is evaluated, how it is validated, and which environment actually owns the value.
Start with the Split Between Server Secrets and Public Config
The first rule is simple: do not mix server‑only secrets and public configuration in one vague grab‑bag module. If a value is private, keep it in server‑only code. If it is genuinely intended for the browser, expose it deliberately and prefix it correctly.
This sounds obvious, but it is where many teams get themselves into trouble. A database URL, CMS token, or private API key should never be anywhere near NEXT_PUBLIC_. Once you put that prefix on a variable, you are telling Next.js to inline it into JavaScript sent to the browser. That is not a subtle security boundary. It is a very explicit one.
I find it much easier to keep this honest when there are separate modules for server and public environment values. That way the boundary is visible in the file structure, not just in our intentions.
// env.server.tsimport 'server-only';const required = (name: keyof NodeJS.ProcessEnv): string => { const value = process.env[name]; if (!value) { throw new Error(`Missing required environment variable: ${name}`); } return value;};const booleanFromEnv = ( value: string | undefined, defaultValue = false): boolean => { if (value == null) { return defaultValue; } return value === 'true';};export const serverEnv = { databaseUrl: required('DATABASE_URL'), contentfulAccessToken: required('CONTENTFUL_ACCESS_TOKEN'), logSql: booleanFromEnv(process.env.LOG_SQL), appEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? 'development',} as const;// env.public.tsconst requiredPublic = (name: `NEXT_PUBLIC_${string}`): string => { const value = process.env[name]; if (!value) { throw new Error(`Missing required public environment variable: ${name}`); } return value;};export const publicEnv = { siteUrl: requiredPublic('NEXT_PUBLIC_SITE_URL'), analyticsId: process.env.NEXT_PUBLIC_ANALYTICS_ID ?? '',} as const;The point is not ceremony for its own sake. The point is that it becomes much harder to accidentally import private configuration into client code when the boundary is already doing some of the work for us.
Use the .env* files deliberately
Next.js has built‑in support for loading values from .env* files into process.env, and the load order matters. If you do not know which file wins, debugging environment issues becomes much more frustrating than it needs to be.
The current official order is:
process.env.env.$(NODE_ENV).local.env.localexcept intest.env.$(NODE_ENV).env
That means a value in .env.development.local will override the same key in .env when you are running locally in development. It also means .env.local is intentionally skipped in tests, because tests should be reproducible and should not quietly depend on one developer's machine‑specific overrides.
In practice, I usually treat the files like this:
.env.localfor local secrets and machine‑specific overrides.env.development.localwhen local development genuinely needs environment‑specific tweaking.env.testfor shared test defaults if the project needs them.env.exampleas documentation for required keys, using placeholder values only
What I generally do not want is a team casually sprinkling real credentials across several .env* files and then hoping everybody remembers which one is active. At that point the file naming convention has stopped being helpful.
One other detail is easy to miss if your project uses a /src directory: the .env.* files still belong at the project root, not inside /src.
Validate Once, Early, and Parse Explicitly
Another easy mistake is to treat process.env as if it were magically typed. It is not. Even at the Node.js level, environment variable values are just text. That means true, 0, 5000, and JSON‑looking blobs all arrive as strings until we parse them ourselves.
That is exactly why I prefer a small validation layer near application startup rather than reading raw environment variables all over the codebase. If a value is required, fail early. If it needs parsing, parse it once. If it has a sensible default, make that explicit.
This is especially useful for values that look boolean or numeric but are not. process.env.ENABLE_CACHE === true will never do what we mean, because the environment value is the string 'true', not the boolean true.
Failing fast helps here too. It is far better for the application to complain immediately that DATABASE_URL is missing than for the first broken request to discover it halfway through a deployment.
Use @next/env outside the Next.js runtime
Next.js does the right thing inside its own runtime, but not every important file in a project runs inside that runtime. ORM config, test setup, migration scripts, Storybook, or custom build utilities often need the same environment loading behaviour without spinning up the whole framework.
That is where @next/env earns its keep. Next.js uses it internally, and it gives us a straightforward way to load the same .env* rules in other tools.
// env.load.tsimport { loadEnvConfig } from '@next/env';export const loadAppEnv = (): void => { loadEnvConfig(process.cwd());};// orm.config.tsimport { loadAppEnv } from './env.load';loadAppEnv();export default defineConfig({ connectionString: process.env.DATABASE_URL!,});The practical win is consistency. If the Next.js app and the surrounding tooling all load variables the same way, you remove a whole class of irritating "works in the app, breaks in the toolchain" problems.
Understand What Changes at Build Time and What Changes at Runtime
This is the part that catches people out most often in real projects. NEXT_PUBLIC_ variables are inlined into the browser bundle during next build. That means they are evaluated when the application is built, not later when the user opens the page.
So if you build once and then promote that same artefact across environments, those public values do not magically refresh themselves afterwards. They are already baked in. The Next.js docs are very clear about this, and it is one of the best reasons to be deliberate about what becomes public config in the first place.
If a value really does need to change at runtime for the browser, the safer approach is usually to read it on the server and expose it through a controlled path, such as server‑rendered data, a route handler, or an initial configuration endpoint. That gives us a real runtime boundary instead of pretending a build‑time substitution is more dynamic than it actually is.
The same operational rule shows up on Vercel as well. Changing an environment variable there does not rewrite old deployments. The new value applies to new deployments only. That is sensible, but it does mean environment‑variable management is partly a deployment concern, not just a code concern.
Treat Deployment Scopes as Part of the Design
On Vercel, environment variables can be scoped to environments such as Preview and Production, and they can also live at team level or project level. That matters because most applications do not want every environment talking to the same services or using the same credentials.
Preview should usually point at preview‑safe dependencies. Production should point at production dependencies. If branch deployments are reading live billing credentials or sending real emails, that is not an environment‑variable mistake in isolation. It is a deployment design mistake.
This is also why I prefer names that describe the system being configured rather than the place the code happens to read it. CONTENTFUL_PREVIEW_ACCESS_TOKEN tells a clearer story than something vague like CMS_TOKEN_2. Environment variables live outside the code, so the naming needs to carry more of the meaning.
A Few Easy Mistakes Worth Avoiding
The first is exposing too much to the client simply because it is convenient. If a client component needs a value, ask whether it genuinely needs the value itself or whether it only needs the result of some server‑side decision.
The second is relying on dynamic access for public variables and then expecting Next.js to inline them. Direct references such as process.env.NEXT_PUBLIC_SITE_URL are the safe route. If you start reaching through indirection, you are more likely to surprise yourself.
The third is leaving required variables undocumented. Even on disciplined teams, a missing .env.example or a missing setup note wastes a remarkable amount of time.
The fourth is forgetting that test behaviour is intentionally different. .env.local is not loaded in test, which is a good thing, but it can look confusing if nobody on the team remembers that rule when a suite starts failing in CI.
Useful References
- Next.js Environment Variables
- Vercel Environment Variables
- Node.js Environment Variables and .env files
Wrapping Up
Good environment‑variable management in Next.js is mostly about being deliberate. Keep secrets on the server, keep public config genuinely public, validate values early, and do not pretend build‑time values are runtime values.
Key Takeaways
NEXT_PUBLIC_is a browser boundary, not a convenience prefix.- The
.env*load order matters, especially once local overrides and tests enter the picture. - Environment values are strings, so required parsing and validation should happen deliberately.
@next/envis the cleanest way to keep scripts, tests, and config files aligned with Next.js itself.- On platforms like Vercel, variable changes apply to new deployments, so configuration and deployment behaviour need to be designed together.
If the environment setup is clear, most of the surrounding application gets easier to reason about. If it is vague, the bugs are rarely interesting. They are just expensive.