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:
| File | When it loads | Committed to git? |
|---|---|---|
.env | Always | Yes |
.env.local | Always (overrides .env) | No |
.env.development | next dev only | Yes |
.env.production | next build / next start | Yes |
.env.development.local | next dev only (overrides .env.development) | No |
.env.production.local | next 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:
.envfor truly global, non-sensitive config (app name, base URLs in some setups).env.localfor 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=productionThen 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 devlocally - 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.localfor 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-nextjsand Zod so missing values fail loudly - Keep a
.env.examplecommitted 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_ENVvariable 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.
