Background image

Next.js Environment Variables: The Missing Guide

23 Dec β€’ 6 min read

blog main image

Next.js environment variables fail silently - unless you force them to fail early.

It's like a loaded gun on the wall. Everything looks fine - right up until it goes off in production.

  • Miss one required variable β†’ production breaks, hard to notice. Best case, the build fails. Tests won't save you if they don't run with real production env values.Solution
  • Expose a secret to the client β†’ it's public the moment you deploy. These leaks are hard to spot and almost always found too late.
  • No TypeScript safety for env vars β†’ mistakes move from compile time to runtime, everything will work locally, but not in prod :)

I learned these lessons the hard way: about ~$1k in losses and a brief service downtime. Honestly, a pretty cheap lesson - thanks to a bit of luck with detection and good monitoring, though it wouldn't have happened if I'd done it right from the start. In this article, we'll make our environment variable setup bulletproof.

This article assume your familiar with process envs, if not please check https://nextjs.org/docs/pages/guides/environment-variables

Solution

It's easy to build an MVP where everything works (at least on your machine πŸ˜…), but once you hit production - with SLA/SLOs, multiple environments, services, and on top of that multiple Next.js execution contexts (server, client, build) - hard-coding configuration in the codebase stops being viable. At that point, configuration has to move into environment variables.

To use them without headaches, we need a few things:

test
  • Types β†’ prevents "wrong usage" at dev time
  • Runtime validation β†’ prevents "wrong/missing value" at startup (build on CI)
  • Security scan β†’ prevents "secrets" shipped to client bundle

Without further ado, let's look at each piece in depth.

Types

Let's start with basics and add types for our process.env, for that we need to create environment.d.ts - so that it will be automatically added into global types, also all major IDE will automatically include it. *(If your prefer different file name, include your types files into tsconfig's "include")

import 'next'

declare global {
    namespace NodeJS {
        interface ProcessEnv {
            CF_CAPTCHA_SCRIPT_URL: string
            CF_CAPTCHA_SITE_KEY: string
            SENTRY_DSN: string
            OPENAI_API_KEY?: string
        }
    }
}
test

Congrats you already solved a lot of problems and now your process.env usage should be pretty straightforward, but job's not finished.

Runtime check

Now that we have types lets add runtime check, for that we could use any type schema validator we have, it could be zod, yup, valibot etc. From the validator, we want two things: runtime validation and TypeScript types. We don't want to define them in two separate places.

Let's create /src/env.ts file, central place to access our environment variables:

import {z} from 'zod'

const EnvSchema = z.object({
    ENV: z.enum(['dev', 'prod']).default('dev'),
    SENTRY_DSN: z.string().url(),
    CF_CAPTCHA_SCRIPT_URL: z.string().url(),
    OPENAI_API_KEY: z.string(),
    CF_CAPTCHA_SITE_KEY: z.string().min(1)
})

export type EnvType = z.infer<typeof EnvSchema>

export const env = EnvSchema.parse(process.env) // <- EnvType

Now that we defined our schema in the EnvSchema we got two things in one:

  • parse - validates our process.env synchronously and fail if smth is wrong
  • EnvType - can be used for typing, along with the exported env

In most cases, this is enough. But we can do more and add extra checks - specific to the environment.

for that we can add:

EnvSchema.superRefine((cfg, ctx) => {
    const isProd = cfg.ENV === 'prod'
    const isStagingSiteKey = cfg.CF_CAPTCHA_SITE_KEY?.includes('x00000000000')
    if (isProd && isStagingSiteKey) {
        ctx.addIssue({
            code: z.ZodIssueCode.custom,
            path: ['cfCaptcha', 'siteKey'],
            message: 'Test siteKey value is not allowed in production.'
        })
    }
})

If we end up having staging key for CF_CAPTCHA_SITE_KEY in prod it will fail.

In general, I find it useful to use a centralized module (a single file) to access environment variables instead of process.env. If you prefer a global approach, we can do it like this:

declare global {
    namespace NodeJS {
        interface ProcessEnv extends z.infer<typeof EnvSchema> {}
    }
}

Now that we've added runtime checks, it's important to understand when they can fail:

  • Build time - ideal place to fail. The app won't be deployed with invalid configuration. In practice, this is what happens in ~99% of cases.
  • Client or server runtime - this is a failure you catch too late. It means the build can differ from runtime for various reasons, and we need to handle that.

When a failure happens at runtime (client/server), you generally have two options:

  • Fail hard and report an error - the user may see a blank screen, or an API may be unavailable.
  • Fail gracefully - run with a partially invalid configuration while reporting the error.

T3 ENV

Plain Zod covers most cases just fine. But it’s worth mentioning an alternative if you need a bit more structure:

  • clear server vs client separation, with helpful errors when variables are misused
  • a framework / validator-agnostic approach that scales beyond a single setup
  • better DX

In that case, t3-env is a good fit.

import {createEnv} from '@t3-oss/env-nextjs'
import {z} from 'zod' // also supports other validators

export const env = createEnv({
    server: {
        SENTRY_DSN: z.string().url(),
        OPENAI_API_KEY: z.string()
    },
    client: {
        NEXT_PUBLIC_CF_CAPTCHA_SCRIPT_URL: z.string().url()
    },
    runtimeEnv: process.env
})

The key win: if you try to access a server-only variable in client code, you’ll get a clear error - on top of type safety and runtime validation.

test

Security Scan

Types and runtime validation protect how env vars are used. They do not protect what actually ships to users - client bundle.

In Next.js, any value that reaches client code (directly or indirectly) is inlined at build time. If a secret leaks there, no runtime check can save you - it's already public.

To close this gap, we add a post-build security scan using TruffleHog https://github.com/trufflesecurity/trufflehog

What we want from it:

  • Detect "secrets" accidentally committed to the repo
  • Detect "secrets" embedded into the client bundle
  • Fail CI before users download them

To install TruffleHog, follow the official docs: https://github.com/trufflesecurity/trufflehog?tab=readme-ov-file#floppy_disk-installation You can also skip the installation and run it directly via Docker.

Scan the client output, not just the repo

Most teams scan source code. That's not enough for Next.js.

We must scan what we ship, not what we wrote.

After next build, client assets live in .next/static. That's where NEXT_PUBLIC_* values and inlined constants end up.

next build

trufflehog filesystem .next/static \
	--no-verification \
	--results=unverified,unknown,verified
  • Why --no-verification? Because even test keys, partial keys, or truncated secrets are a problem once shipped. Verification is optional - exposure is not.

What we could/should improve?

  • We should also add pulling production env variable: vercel env pull .env.production --environment=production to test on real production like build.
  • Check source code as well as git history - it's pretty straight forward with trufflehog (trufflehog git)

Conclusion

If you rely on untyped process.env, assume staging equals production, or trust that secrets won't leak to the client, you're relying on assumptions. Next.js doesn't guarantee any of them.

The fix is small: add types, runtime validation, and scan what you ship. Do that, and most failure modes disappear.

Best part? You only need to learn this lesson once - ideally without an outage.

  • next-runtime-env Helps avoid Next.js environment variables being frozen at build time. Useful when you need runtime config or reusable Docker images.

  • trufflehog Scans repositories, git history, and build output for leaked secrets. Works well as a CI safety net before anything ships.

  • t3 env Framework-agnostic environment variable validation with explicit server/client separation.

  • Env type safety & validation A deeper dive into the first two topics, with practical examples of typing and validating environment variables.


nextjs
env
typescript
security
react
zod