How to Use Environment Variables in Next.js — And Stop Leaking API Keys
Environment variables in Next.js are simple in theory and surprisingly easy to get wrong in practice.
The most common mistake? Prefixing a secret API key with NEXT_PUBLIC_ and shipping it to every browser that visits your site. The key is now public. The damage is done.
This guide covers exactly how environment variables work in Next.js — which variables go where, why the NEXT_PUBLIC_ prefix matters more than most developers realize, how to set them up locally and on Vercel, and the mistakes that have burned real production apps.
🎯 Quick Answer (30-Second Read)
- Server-only variables: No prefix —
DATABASE_URL,STRIPE_SECRET_KEY— never sent to the browser - Client-side variables: Must use
NEXT_PUBLIC_prefix —NEXT_PUBLIC_SUPABASE_URL— visible to everyone - Local development: Store in
.env.local— never commit this file to Git - Production: Set in Vercel dashboard under Project Settings → Environment Variables
- Runtime vs build time:
NEXT_PUBLIC_variables are inlined at build time — changing them requires a redeploy - Golden rule: If it is a secret, it never gets
NEXT_PUBLIC_
How Next.js Handles Environment Variables
This is the single most important thing to understand about Next.js environment variables. The NEXT_PUBLIC_ prefix is not just a naming convention — it is a build-time instruction that tells Next.js to bundle that variable directly into your client-side JavaScript.
The .env File Hierarchy
Next.js reads environment variables from these files in order of priority:
.env.local # highest priority — your local overrides, never commit
.env.development # loaded when NODE_ENV=development
.env.production # loaded when NODE_ENV=production
.env # lowest priority — base defaults, safe to commit if no secretsFor most projects, you only need two files:
.env.local # all your actual secrets and local config
.env.example # committed to Git, shows what variables are needed (no values)Your .gitignore should always include:
# .gitignore
.env.local
.env.development.local
.env.production.local
.env*.localStep-by-Step Setup
Step 1 — Create your .env.local file
touch .env.localAdd your variables:
# .env.local
# Server-only — secrets, never use NEXT_PUBLIC_ here
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
NEXTAUTH_SECRET=your_nextauth_secret_minimum_32_chars
ANTHROPIC_API_KEY=sk-ant-your_anthropic_key
# Client-safe — fine to expose, needed in browser
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key
NEXT_PUBLIC_APP_URL=http://localhost:3000Step 2 — Access variables in Server Components
Server Components run only on the server. All environment variables — with or without NEXT_PUBLIC_ — are available:
// src/app/dashboard/page.tsx — Server Component
export default async function DashboardPage() {
// Both work in server components
const dbUrl = process.env.DATABASE_URL
const appUrl = process.env.NEXT_PUBLIC_APP_URL
const data = await fetch('https://api.example.com/data', {
headers: {
// Safe — this code never runs in the browser
Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`
}
})
return {/* render data */}
}Step 3 — Access variables in Client Components
Client Components run in the browser. Only NEXT_PUBLIC_ variables are available. Accessing a server-only variable returns undefined — it does not throw an error, which makes this bug silent and hard to catch:
'use client'
// src/components/PaymentForm.tsx — Client Component
import { loadStripe } from '@stripe/stripe-js'
// ✅ Works — NEXT_PUBLIC_ variable available in browser
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
// ❌ Silently returns undefined — secret key is NOT available in browser
// Never do this — if it worked, your secret key would be exposed
const secretKey = process.env.STRIPE_SECRET_KEY // undefined
export default function PaymentForm() {
return {/* payment UI */}
}Step 4 — Access variables in API Route Handlers
Route Handlers are server-side. All variables are available:
// src/app/api/checkout/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const { priceId } = await req.json()
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`
})
return Response.json({ url: session.url })
}Step 5 — Validate environment variables at startup
Silent undefined errors are the worst kind of bug — your app starts, looks fine, and fails mysteriously at runtime. Validate required variables at startup using Zod:
// src/lib/env.ts
import { z } from 'zod'
const envSchema = z.object({
// Server-only
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
NEXTAUTH_SECRET: z.string().min(32),
// Client-safe
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
NEXT_PUBLIC_APP_URL: z.string().url(),
})
export const env = envSchema.parse(process.env)Import env from this file instead of using process.env directly:
// Instead of this
const dbUrl = process.env.DATABASE_URL
// Do this — throws at startup if variable is missing or malformed
import { env } from '@/lib/env'
const dbUrl = env.DATABASE_URLStep 6 — Set environment variables on Vercel
- Go to your Vercel project dashboard
- Click Settings → Environment Variables
- Add each variable with its value
- Select the correct scope — Production, Preview, or Development
- Click Save — a redeploy is required for changes to take effect
# Production scope — real keys
STRIPE_SECRET_KEY=sk_live_your_live_key
DATABASE_URL=your_production_database_url
# Preview scope — test keys for PR preview deployments
STRIPE_SECRET_KEY=sk_test_your_test_key
DATABASE_URL=your_staging_database_urlTo pull Vercel environment variables to your local machine:
vercel env pull .env.localThe .env.example Pattern
Always commit a .env.example file that documents every required variable without actual values. This tells new developers (and your future self) exactly what needs to be configured:
# .env.example — commit this to Git
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Stripe
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# Auth
NEXTAUTH_SECRET=generate_with_openssl_rand_base64_32
NEXT_PUBLIC_APP_URL=http://localhost:3000Common Mistakes and Fixes
| Mistake | What Happens | Fix |
|---|---|---|
NEXT_PUBLIC_ on a secret key |
Key exposed in browser JS bundle | Remove prefix, access only server-side |
| Accessing server var in Client Component | Returns undefined silently |
Move logic to Server Component or API route |
Committing .env.local to Git |
Secrets exposed in repo history | Add to .gitignore, rotate all exposed keys immediately |
Changing NEXT_PUBLIC_ var without redeploy |
Old value still served | Redeploy — build-time vars require a new build |
| Missing var in production | Runtime crash or silent failure | Use Zod validation at startup to catch on boot |
Using NEXT_PUBLIC_ for Supabase service role key |
Full database access exposed | Service role key is server-only — never prefix it |
TypeScript: Type-Safe Environment Variables
By default process.env returns string | undefined for everything. Add type safety with a declaration file:
// src/types/env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
// Server-only
DATABASE_URL: string
STRIPE_SECRET_KEY: string
SUPABASE_SERVICE_ROLE_KEY: string
NEXTAUTH_SECRET: string
// Client-safe
NEXT_PUBLIC_SUPABASE_URL: string
NEXT_PUBLIC_SUPABASE_ANON_KEY: string
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: string
NEXT_PUBLIC_APP_URL: string
}
}This removes undefined from the type so you do not need ! non-null assertions everywhere. Combined with the Zod validation at startup, your environment variable handling is fully type-safe and validated.
Real Developer Use Case
A developer shipping a SaaS on Next.js and Supabase accidentally prefixed their SUPABASE_SERVICE_ROLE_KEY with NEXT_PUBLIC_. The app worked perfectly. No errors. No warnings.
Three weeks later a security researcher emailed them. The service role key — which bypasses Supabase Row Level Security entirely and has full database access — was visible in the browser's network tab on every page load.
They rotated the key, removed the prefix, redeployed. But the key had been public for three weeks in a production app with real user data.
The fix is one line: remove NEXT_PUBLIC_ from the variable name. The Zod validation pattern at startup would have also caught this — the schema could enforce that service role keys are never prefixed by simply keeping them out of the NEXT_PUBLIC_ section entirely.
Frequently Asked Questions
Why does my environment variable work locally but return undefined in production?
The most common cause is forgetting to add the variable to your Vercel project settings. Variables in .env.local are only available locally — they are never uploaded to Vercel automatically. Go to your Vercel project → Settings → Environment Variables and add every variable your app needs.
Can I use environment variables in next.config.js?
Yes. next.config.js runs on the server at build time, so all variables are available via process.env. You can also expose specific variables to the client using the env key in next.config.js without the NEXT_PUBLIC_ prefix — though the NEXT_PUBLIC_ convention is cleaner and more explicit.
Do NEXT_PUBLIC_ variables update without a redeploy?
No. NEXT_PUBLIC_ variables are inlined into your JavaScript bundle at build time. If you change the value in Vercel, the old value is still served until you trigger a new deployment. Server-only variables (without the prefix) are read at runtime and do update without a redeploy on platforms that support it.
Is it safe to commit .env to Git (without .local)?
Only if it contains no real secrets — default values, public URLs, non-sensitive config. The .env file is loaded at lowest priority and is typically used for safe defaults that work in any environment. Secrets always go in .env.local which is gitignored.
How do I share environment variables with my team without committing them?
Use a secrets manager like Doppler, Infisical, or 1Password Secrets Automation. These tools sync environment variables to your local machine and Vercel without storing them in Git. For smaller teams, the Vercel CLI vercel env pull command lets teammates pull the project's environment variables directly from Vercel to their local .env.local.
Conclusion
Environment variables in Next.js follow one rule that covers 90% of mistakes: if it is secret, no NEXT_PUBLIC_ prefix, ever.
Server Components, API routes, and middleware can access everything. Client Components can only access NEXT_PUBLIC_ variables — and those variables are public to the entire internet the moment your app is deployed.
Set up Zod validation at startup, commit a .env.example, and use Vercel's scoped environment variables to keep production and preview environments cleanly separated.
Get this right once and you will never accidentally ship a secret key again.
Related reads: How to Deploy Next.js on Vercel Step-by-Step · How to Create a SaaS with Next.js and Supabase · How to Build a Chrome Extension with AI · Claude Code Setup Guide