Plainform

Mobile Sidebar

Mobile Sidebar

Managing Environment Variables Across Development, Staging and Production in Next.js

3 Jun 2026
10 minute read
R
Ronald SolticzkiBackend Engineer
Managing Environment Variables Across Development, Staging and Production in Next.js

Next.js environment variables are easy to get working for a single environment. You add values to .env.local, reference them in your code, and everything works. The complexity comes when you need different values in development, staging, and production — and when you need to make sure the wrong values never end up in the wrong place.

This post covers how to structure Next.js environment variables across multiple environments, how to validate them so missing values fail loudly at build time, and how Plainform handles this in a real production SaaS setup.

How Next.js Loads .env Files

Before covering multi-environment strategy, it helps to know exactly which files Next.js reads and in what order. Next.js supports a layered .env system where more specific files override less specific ones:

FileWhen it loadsCommitted to git?
.envAlwaysYes
.env.localAlways (overrides .env)No
.env.developmentnext dev onlyYes
.env.productionnext build / next startYes
.env.development.localnext dev only (overrides .env.development)No
.env.production.localnext build only (overrides .env.production)No

The .local variants are always gitignored by default. The non-local variants can safely hold non-sensitive defaults or feature flags that differ by environment.

What most projects actually use

In practice, most Next.js SaaS projects end up with:

  • .env for truly global, non-sensitive config (app name, base URLs in some setups)
  • .env.local for all secrets in local development
  • Environment variables set in the hosting provider's dashboard for staging and production

The environment-specific .env.development and .env.production files are useful but often skipped in favour of just managing everything through the hosting provider. Either approach works — the key is being consistent.

The Server vs. Client Split

Next.js environment variables have a fundamental security boundary: variables without the NEXT_PUBLIC_ prefix are server-only and never included in the browser bundle. Variables with NEXT_PUBLIC_ are inlined into the client JavaScript at build time.

This split becomes more important across multiple environments because you have to think about it twice:

  • Do I want this value on the server only, or does the client need it too?
  • Does the value differ between environments?

A service like Stripe has both: a server-side secret key (STRIPE_SECRET_KEY) that must never reach the browser, and a publishable key (STRIPE_PUBLISHABLE_KEY) that the frontend needs to initialize the Stripe SDK. Both values differ between your test and live Stripe environments. That gives you four distinct variables to manage correctly across environments.

Structuring Variables by Environment

Development

In development, all sensitive values live in .env.local. This file is gitignored and never committed. Every developer on your team has their own copy with their own credentials.

A typical .env.local for a Next.js SaaS app:

# Site
NEXT_PUBLIC_SITE_URL=http://localhost:3000
SITE_URL=http://localhost:3000

# Clerk (test keys)
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

# Stripe (test keys)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Database (local or dev branch)
DATABASE_URL=postgresql://...
DIRECT_URL=postgresql://...

# Email
RESEND_API_KEY=re_...
RESEND_AUDIENCE_ID=...

# Analytics
NEXT_PUBLIC_POSTHOG_KEY=phc_...
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com

# Storage
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_S3_BUCKET=my-app-dev
AWS_S3_REGION=eu-west-1
AWS_S3_ENDPOINT=...

The key discipline here: always use test/sandbox credentials in development. Stripe test keys, Clerk development keys, a dev database, a dev S3 bucket. Never use production credentials locally.

Staging

Staging is where most teams run into trouble. It is a real deployment, so it feels like production — but it should be isolated from production data and credentials.

The right setup for staging:

  • Stripe: test mode keys (same tier as development, not live keys)
  • Database: a separate staging database, not a copy of production
  • Auth: a separate Clerk application or at minimum a separate Clerk environment
  • Storage: a separate S3 bucket prefixed with staging-
  • Email: a test/sandbox sender, or a real sender pointed at internal addresses only
  • Analytics: the same PostHog project as production is usually fine, using environment properties to filter

These values live as environment variables in your staging deployment on Vercel, Railway, Render, or wherever you host. They are not in any .env file in source control.

Production

Production variables follow the same pattern — set directly in your hosting provider, never in source control. The difference is these are live credentials: live Stripe keys, the real database, the real S3 bucket.

The discipline that matters most in production:

  • No developer's machine should ever have production credentials in their .env.local
  • Credential rotation is easier when values are only in the hosting provider's dashboard
  • Audit who has access to the hosting provider's environment variable settings

Validating Variables at Build Time

The default behaviour when a Next.js environment variable is missing is that it is simply undefined. Your app starts, runs fine in the paths that do not need that variable, and then fails silently — or loudly, at runtime, when a user hits the code path that needs it.

For a production SaaS app, that is not acceptable. The right behaviour is to fail loudly at build time: if a required variable is missing, the build fails and nothing deploys.

Setting up @t3-oss/env-nextjs

@t3-oss/env-nextjs with Zod schemas is the standard solution. You declare every variable once, with its type and validation rules. The validation runs at build time — if anything is missing or malformed, the build fails with a clear error.

Here is how Plainform's env.ts looks:

import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const env = createEnv({
  client: {
    NEXT_PUBLIC_SITE_URL: z.string().min(5),
    NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.string().min(5),
    NEXT_PUBLIC_CLERK_SIGN_UP_URL: z.string().min(5),
    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(5),
    NEXT_PUBLIC_POSTHOG_KEY: z.string().min(5),
    NEXT_PUBLIC_POSTHOG_HOST: z.string().min(5),
  },
  server: {
    SITE_URL: z.string().min(5),
    CLERK_SECRET_KEY: z.string().min(5),
    STRIPE_SECRET_KEY: z.string().min(5),
    STRIPE_PUBLISHABLE_KEY: z.string().min(5),
    STRIPE_WEBHOOK_SECRET: z.string().min(5),
    DATABASE_URL: z.string().min(5),
    DIRECT_URL: z.string().min(5),
    AWS_ACCESS_KEY_ID: z.string().min(5),
    AWS_SECRET_ACCESS_KEY: z.string().min(5),
    AWS_S3_ENDPOINT: z.string().min(5),
    AWS_S3_REGION: z.string().min(5),
    AWS_S3_BUCKET: z.string().min(5),
    RESEND_API_KEY: z.string().min(5),
    RESEND_AUDIENCE_ID: z.string().min(5),
    MAILCHIMP_API_KEY: z.string().min(5),
    MAILCHIMP_API_SERVER: z.string().min(3).max(5),
    MAILCHIMP_AUDIENCE_ID: z.string().min(5),
    EVENT_API_SECRET: z.string().min(5),
  },
  experimental__runtimeEnv: {
    NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
    NEXT_PUBLIC_CLERK_SIGN_IN_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL,
    NEXT_PUBLIC_CLERK_SIGN_UP_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL,
    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
    NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
    NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
  },
});

Every variable is declared. Every variable has a schema. If staging is missing STRIPE_WEBHOOK_SECRET, the staging build fails before anything deploys. If a developer forgets to add RESEND_API_KEY to their .env.local, their local build fails with a clear message about which variable is missing.

Using env instead of process.env

Once you have env.ts, import from it everywhere instead of accessing process.env directly:

// Good
import { env } from '@/env';
const stripe = new Stripe(env.STRIPE_SECRET_KEY);

// Avoid
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

Direct process.env access bypasses validation and gives you string | undefined instead of string. The non-null assertion (!) is a code smell that hides the real problem.

The .env.example File

For teams, a .env.example file committed to source control documents every variable the project needs, without containing any real values:

# Site
NEXT_PUBLIC_SITE_URL=
SITE_URL=

# Clerk
CLERK_SECRET_KEY=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

# Stripe
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=

# Database
DATABASE_URL=
DIRECT_URL=

# Email
RESEND_API_KEY=
RESEND_AUDIENCE_ID=
MAILCHIMP_API_KEY=
MAILCHIMP_API_SERVER=
MAILCHIMP_AUDIENCE_ID=

# Storage
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_S3_ENDPOINT=
AWS_S3_REGION=
AWS_S3_BUCKET=

# Analytics
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=

# Internal
EVENT_API_SECRET=
GITHUB_PAT_TOKEN=

When a new developer joins, they copy .env.example to .env.local and fill in their credentials. When a new variable is added to the project, the PR that adds it should also update .env.example and env.ts. That keeps the three sources of truth — example file, validation schema, and actual values — in sync.

Environment-Specific Behaviour in Code

Some behaviour should differ between environments beyond just variable values. Next.js exposes process.env.NODE_ENV which is always one of development, test, or production.

if (process.env.NODE_ENV === 'development') {
  // extra logging, debug tools, etc.
}

For finer-grained environment detection — distinguishing staging from production when both run with NODE_ENV=production — you can use a custom variable:

# In staging environment
NEXT_PUBLIC_APP_ENV=staging

# In production environment
NEXT_PUBLIC_APP_ENV=production

Then in code:

const isStaging = env.NEXT_PUBLIC_APP_ENV === 'staging';
const isProd = env.NEXT_PUBLIC_APP_ENV === 'production';

This is useful for things like showing a staging banner, enabling verbose error messages, or pointing PostHog events to a separate staging project.

Common Multi-Environment Mistakes

Using production credentials in staging

Staging should be a safe place to test destructive operations — resetting data, triggering webhooks, testing payment flows. If staging points at your real Stripe live keys or your real production database, a staging test can corrupt production data or charge real users. Always use test/sandbox credentials in staging.

Forgetting NEXT_PUBLIC_ variables are build-time

Client-side variables with NEXT_PUBLIC_ are baked into the JavaScript bundle at build time. If you update a NEXT_PUBLIC_ value in your hosting provider and do not redeploy, the old value is still in the bundle. This is a common source of confusion when updating something like a Clerk publishable key or a PostHog host.

Server-side variables (without NEXT_PUBLIC_) are read at runtime on each request, so they update without a rebuild on serverless platforms.

Sharing webhook secrets across environments

Stripe generates a separate webhook secret (STRIPE_WEBHOOK_SECRET) for each webhook endpoint. Your development endpoint, staging endpoint, and production endpoint each have their own secret. Using the wrong secret means webhook signature verification will fail — or worse, succeed for traffic it should not accept.

Not rotating secrets after a leak

If a secret is accidentally committed to git, rotating it is not optional — it is urgent. Change the value in your hosting provider immediately, update .env.local for all developers, and push a new commit that removes the leaked value from git history using git filter-repo or a similar tool. GitHub's secret scanning will also alert you if it detects common secret patterns in a push.

Deploying to Vercel

Vercel is the most common hosting platform for Next.js apps, and it has first-class support for per-environment variables. In the Vercel dashboard under Settings → Environment Variables, you can assign each variable to one or more environments:

  • Development — available when running vercel dev locally
  • Preview — used for preview deployments (pull request branches), which maps to staging
  • Production — used only for the production deployment

This means you set STRIPE_SECRET_KEY three times: once with a test key for Development, once with a test key for Preview, and once with the live key for Production. Vercel injects the right value at build time for each deployment.

The same pattern applies to Railway, Render, Fly.io, and other providers — the UI differs but the concept is identical.

Summary

Managing Next.js environment variables across multiple environments comes down to a few clear rules:

  • Use .env.local for local development secrets, never committed to source control
  • Use test/sandbox credentials for development and staging — never live credentials
  • Set production variables directly in your hosting provider's dashboard
  • Validate all variables at build time with @t3-oss/env-nextjs and Zod so missing values fail loudly
  • Keep a .env.example committed as the authoritative list of required variables
  • Remember that NEXT_PUBLIC_ variables are baked in at build time — updating them requires a redeploy
  • Use a custom APP_ENV variable when you need to distinguish staging from production in code

Get this structure right once and it scales cleanly as your team and environment count grows. The discipline of separating credentials by environment, validating them at build time, and documenting them in .env.example prevents the entire class of "it works in dev but breaks in production" environment variable bugs.

Comments on this page

Leave comment

Stay up to date with our latest product updates. Unsubscribe anytime!

2026 © All rights reserved