Environment variables are how you keep secrets out of your codebase and configuration flexible across environments. Next.js has a built-in env var system that handles most of this automatically — but it has one sharp edge that trips up almost every developer: anything prefixed with NEXT_PUBLIC_ gets bundled into your client-side JavaScript and is visible to every user who opens DevTools. If you put a secret there, it is no longer a secret.
This guide covers the complete pattern for managing environment variables in Next.js: the difference between server and client vars, the .env.local workflow, how to add vars in Vercel, and how to write type-safe access so missing variables fail loudly at startup instead of silently at runtime.
Server-only vs client-visible variables
Next.js splits environment variables into two buckets based on a naming prefix:
- No prefix (e.g.
DATABASE_URL) — server-only. These are available in Server Components, Route Handlers, and getServerSideProps, but are never included in the JavaScript bundle sent to the browser. This is where secrets belong. NEXT_PUBLIC_ prefix (e.g. NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) — replaced at build time and bundled into client JavaScript. Anyone who visits your site can read these values by viewing page source or opening the Network tab. Use this only for values that are safe to be public.
The build-time replacement for NEXT_PUBLIC_ vars is important to understand: Next.js literally inlines the value as a string literal during the build. This means if you change a NEXT_PUBLIC_ variable in Vercel after a deployment, the old value is still in the deployed bundle until you trigger a new build.
The .env.local and .env.example pattern
The standard workflow uses two files:
.env.local — your actual secrets and keys. This file is never committed to git. Add it to .gitignore immediately (Next.js does this automatically when you use create-next-app)..env.example — a template committed to the repo with all the variable names but empty or placeholder values. This is documentation for other developers (and your future self) about what the app needs to run.
A typical .env.example looks like this:
# Authentication (Clerk)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
# Database (Supabase)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
# Payments (Stripe)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Email (Resend)
RESEND_API_KEY=re_...
When a new developer clones the repo, they run cp .env.example .env.local and fill in their own values. No secret is ever shared through the repository.
Why you should never put secrets in NEXT_PUBLIC_ variables
This is the mistake that causes real security incidents. Consider what happens if you accidentally write:
# WRONG — this key has full database access
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJ...
The service role key bypasses Row Level Security and can read or delete every row in your database. Once you deploy with this prefix, it is embedded in your JavaScript bundle, served to every visitor, and indexable by search engines. Rotating the key is painful — you have to update the value, redeploy, and hope no one captured the old key.
The same applies to STRIPE_SECRET_KEY, RESEND_API_KEY, OPENAI_API_KEY, and any other key that can take destructive action or incur charges. Keep them server-only.
The public Supabase anon key and the Stripe publishable key are designed to be exposed — they have their own security model (RLS and domain restrictions respectively) that assumes they will be seen by clients.
How to add environment variables in the Vercel dashboard
Local .env.local files are only for your development machine. In production, you configure env vars through Vercel:
- Open your project in the Vercel dashboard
- Go to Settings → Environment Variables
- For each variable, set the name, value, and which environments it applies to (Production, Preview, Development)
- Click Save — the variables take effect on the next deployment
A few things to know about Vercel env vars:
- They are encrypted at rest. Vercel stores them securely and injects them at build and runtime — you do not need to manage secret rotation on the server.
- Preview and Production can have different values. Use your Stripe test keys in Preview environments and live keys only in Production. This prevents test traffic from hitting your live payment processing.
- Changes require a new deployment. Updating a
NEXT_PUBLIC_ variable requires a redeploy because the value is inlined at build time. Server-only variables (used in Route Handlers) take effect immediately on the next request after you save, without a rebuild — but the safest approach is always to redeploy.
Type-safe environment variable access
By default, process.env.SOME_VAR returns string | undefined. If you access a missing variable, you get undefined and a runtime error somewhere deep in your code — often a confusing one. The fix is to validate your env vars at startup and fail fast with a clear message.
Create a small validation module that runs when the server starts:
// lib/env.ts
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(
`Missing required environment variable: ${name}. Check your .env.local file or Vercel dashboard.`
);
}
return value;
}
// Server-only vars — import this only in server files
export const env = {
clerkSecretKey: requireEnv("CLERK_SECRET_KEY"),
supabaseServiceKey: requireEnv("SUPABASE_SERVICE_ROLE_KEY"),
stripeSecretKey: requireEnv("STRIPE_SECRET_KEY"),
stripeWebhookSecret: requireEnv("STRIPE_WEBHOOK_SECRET"),
resendApiKey: requireEnv("RESEND_API_KEY"),
} as const;
Now instead of sprinkling process.env.STRIPE_SECRET_KEY! (the non-null assertion is a red flag) throughout your codebase, you import from lib/env.ts:
import { env } from "@/lib/env";
const stripe = new Stripe(env.stripeSecretKey, { apiVersion: "2024-04-10" });
If STRIPE_SECRET_KEY is missing, your Next.js server will refuse to start and print a clear error. You will catch it in your first deployment rather than in a 2 AM incident when a user tries to check out.
For more robust validation with Zod schemas and TypeScript inference, the t3-env package is worth looking at — but the hand-rolled version above is sufficient for most projects and has zero dependencies.
Common mistakes
Missing variables in production
The most frequent deployment issue. You add a new env var to .env.local, your feature works locally, you deploy — and it crashes in production because you forgot to add the var to Vercel. The type-safe pattern above catches this immediately. Alternatively, add a startup check in your instrumentation.ts (Next.js 15+) that verifies all required vars are present.
Committing .env.local
Check your .gitignore before your first commit. If .env.local is not listed there, add it. If you already committed it by accident, rotate every key in that file immediately — assume the secret is compromised. Then use git filter-repo to scrub the history.
Using NEXT_PUBLIC_ for feature flags you want to change without deploys
Because NEXT_PUBLIC_ vars are inlined at build time, changing them always requires a new build. If you want runtime-configurable feature flags, store them in your database or a service like PostHog feature flags — not in env vars.
The environment variable pattern described here — .env.example for documentation, .env.local for local secrets, Vercel dashboard for production, and a typed validation module — is pre-wired into GetLaunchpad, a Next.js 16 SaaS boilerplate. Every key used by Clerk, Supabase, Stripe, Resend, and PostHog is documented in .env.example and validated at startup.