Manage .env files, Vercel environment variables, and secret rotation safely.
Before you configure anything, run this command in your project root and look at the output carefully:
git log --all --full-history -- .env
If that returns any commits, your .env file has been in your git history at some point. Whether it's still exposed depends on what was in it and whether the repo is public. This lesson starts there — with the state of your current project — not with a blank slate.
⬡ What you'll build
This is the exact sequence. It happens during initial project setup when you're moving fast and haven't thought about secrets yet.
# 1. You create the .env file with real credentials
echo "STRIPE_SECRET_KEY=sk_live_abc123" > .env
echo "DATABASE_URL=postgresql://user:password@host/db" >> .env
# 2. You forget to add .env to .gitignore first
# (or .gitignore doesn't exist yet)
# 3. You do a broad add
git add .
# 4. .env gets staged — you don't notice
git status
# Changes to be committed:
# new file: .env ← right there, but easy to miss in a long list
# 5. You commit
git commit -m "initial setup"
# 6. You push
git push origin main
# Credentials are now in the remote repository's history.
# Even if you delete .env and commit again, the credentials
# remain accessible via: git log --all -- .env
# Or by checking out that specific commit.
Once a credential is in git history and pushed to a remote repo, treat it as compromised regardless of whether the repo is private. Private repos can be made public, forked, or accessed by collaborators. The credential needs to be rotated.
To check if this happened to you:
# Check if .env was ever committed
git log --all --full-history -- .env
# See what was actually in it at that commit
git show <commit-hash>:.env
# Check for other env file variants that might have been committed
git log --all --full-history -- .env.local
git log --all --full-history -- .env.production
The correct structure before writing a single line of application code:
Step 1: Create .gitignore with env file exclusions
# .gitignore — these lines must be present before you create any .env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local
The .env.example file is the exception — it should be committed. It's documentation, not secrets.
Step 2: Create .env.example with all required variable names
# .env.example
# Copy this file to .env and fill in real values.
# NEVER put real values in this file.
# This file is committed to git — it is documentation only.
# Database
DATABASE_URL=postgresql://user:password@host:5432/dbname
# Authentication
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
NEXTAUTH_URL=http://localhost:3000
# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# Email
RESEND_API_KEY=re_...
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
Step 3: Create .env with real values
cp .env.example .env
# Now edit .env with actual credentials — never commit this file
What goes in .env vs hardcoded:
| Type | .env | Hardcoded |
|---|---|---|
| API keys, secrets | Always | Never |
| Database credentials | Always | Never |
| OAuth client IDs/secrets | Always | Never |
| Feature flag defaults | Optional | Acceptable |
| App name, version | No need | Fine |
| Public CDN URLs (no auth) | No need | Fine |
The rule: if rotating the value would require a code change and redeploy, it's misconfigured. Credentials must be environment variables.
⚠.env.example is documentation — keep it current
Every environment variable your application requires must have an entry in .env.example with a placeholder value. When you add a new integration that requires a new variable, update .env.example in the same commit. A new developer (or a new AI session) starting from your repo should be able to look at .env.example and know exactly what credentials they need to acquire before the app will run. If .env.example is stale, that onboarding breaks silently — the app starts but behaves incorrectly because a required variable is missing.
Vercel has three scopes for environment variables. Setting a variable in the wrong scope is one of the most common causes of "works in preview, broken in production" or "works locally, broken everywhere else."
The three scopes:
| Scope | When it applies | Common use |
|---|---|---|
| Production | Deployments from your production branch | Live API keys, production DB URL |
| Preview | All branch deployments that aren't production | Staging API keys, test DB URL |
| Development | vercel dev local development with Vercel CLI | Same as local .env, rarely needed |
Adding a variable via Vercel CLI:
# Add a variable interactively — prompts for value, scope
vercel env add STRIPE_SECRET_KEY
# Pull current Vercel env vars down to a local file
# Useful for syncing your local .env to what Vercel has
vercel env pull .env.local
Adding variables via Vercel dashboard:
Project → Settings → Environment Variables → Add New
Set the variable name, value, and check exactly which scopes it applies to. The default is all three — that's often not what you want for credentials that differ between environments.
The NEXT_PUBLIC_ prefix:
Next.js has a hard boundary between server-side and client-side code. Environment variables are server-side only by default. To expose a variable to the browser (client components, client-side JavaScript), the variable name must start with NEXT_PUBLIC_.
# Server-side only — never sent to the browser
STRIPE_SECRET_KEY=sk_live_...
DATABASE_URL=postgresql://...
NEXTAUTH_SECRET=...
# Exposed to the browser — visible in page source, network requests
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
NEXT_PUBLIC_APP_URL=https://yourapp.com
NEXT_PUBLIC_ANALYTICS_ID=UA-...
This is not a runtime check. The NEXT_PUBLIC_ prefix is processed at build time. Next.js inlines the values of NEXT_PUBLIC_ variables directly into the JavaScript bundle during next build. This has two implications:
NEXT_PUBLIC_ variable changes — the old build has the old value baked inNEXT_PUBLIC_ values — never put secrets thereServer-side (Server Components, API routes, server actions):
// app/api/checkout/route.ts
export async function POST(request: Request) {
const stripeKey = process.env.STRIPE_SECRET_KEY // works — server-only context
if (!stripeKey) {
throw new Error('STRIPE_SECRET_KEY is not configured')
}
// ...
}
Client-side (Client Components, browser code):
'use client'
// app/components/PaymentButton.tsx
export function PaymentButton() {
// WORKS — NEXT_PUBLIC_ prefix makes it available in browser bundle
const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
// SILENT FAILURE — this returns undefined in the browser
// No error. No warning. Just undefined.
const secretKey = process.env.STRIPE_SECRET_KEY
return <button onClick={() => initStripe(publishableKey)}>Pay</button>
}
The silent failure is the trap. When you access a server-side variable in a client component, Next.js does not throw an error. It does not warn you. It returns undefined. The variable appears to exist in code — it exists in your .env, it's set in Vercel — but at runtime in the browser it's simply undefined.
This surfaces as: a feature that works locally (where you might accidentally be testing in a server context) and silently fails on the client, or a TypeError: Cannot read properties of undefined three function calls deep from where the env var was read.
How to avoid it: Type your configuration:
// lib/config.ts
// Fail loudly at startup if required variables are missing
export const config = {
// Server-only — will throw if accessed in client code
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? (() => {
throw new Error('STRIPE_SECRET_KEY is required')
})(),
// Client-safe — NEXT_PUBLIC_ prefix required
stripePublishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? (() => {
throw new Error('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is required')
})(),
}
Validate on startup rather than at the point of use. A missing env var that throws immediately on server start is faster to diagnose than one that returns undefined inside a payment flow.
When a credential is compromised — whether through git history exposure, a data breach at a third-party service, or a secret accidentally logged — rotation is the only correct response. Do it immediately.
The rotation sequence:
# Step 1: Revoke the credential at its source
# Log into Stripe/SendGrid/AWS/etc. and invalidate the exposed key.
# Do this FIRST — before rotating locally or in Vercel.
# A key that exists somewhere is a key that can be used.
# Step 2: Generate/retrieve the new credential
# Create a new key at the provider. Note it.
# Step 3: Update in Vercel
vercel env rm STRIPE_SECRET_KEY production
vercel env add STRIPE_SECRET_KEY
# Enter new value when prompted, select Production scope
# Step 4: Update locally
# Edit .env, replace old value with new value
# Step 5: Trigger a redeploy so Vercel picks up the new value
git commit --allow-empty -m "chore: rotate stripe secret key"
git push
# Step 6: Verify the feature works with the new key
# Test the specific flow that uses this credential
If the credential was committed to git history:
# Install git-filter-repo (better than filter-branch)
pip install git-filter-repo
# Remove the file from all history
git filter-repo --path .env --invert-paths
# If the credential was in a specific file (not .env),
# replace the value across all history
git filter-repo --replace-text <(echo "sk_live_abc123==>REDACTED")
# Force push to remote (coordinate with your team if others have cloned the repo)
git push origin --force --all
git push origin --force --tags
✕Force push does not protect already-cloned repos
After removing a secret from git history and force-pushing, the secret is removed from the remote. But anyone who cloned or forked the repo before the force-push still has the secret in their local copy. GitHub also caches repository content and may still serve the old commit for a period. Credential rotation at the source is not optional — it must happen regardless of whether you clean the git history.
Failure 1: Variable not set at all in Vercel
Error: environment variable DATABASE_URL is not defined
at lib/db.ts:12:11
at Server.start (server.js:45:5)
Cause: Variable exists in .env.local but was never added to Vercel. Fix: Add it in Vercel dashboard → Settings → Environment Variables, then redeploy.
Failure 2: NEXT_PUBLIC_ missing from client-accessible variable
TypeError: Cannot read properties of undefined (reading 'charAt')
at StripeProvider (components/StripeProvider.tsx:8:32)
The actual cause won't mention the env var. You set STRIPE_PUBLISHABLE_KEY in Vercel and in your code, but the component is a client component and the variable name lacks the NEXT_PUBLIC_ prefix. Fix: rename the variable to NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY in both Vercel and code, then redeploy.
Failure 3: Variable set in Development scope only, not Production
Symptom: Feature works in local dev, works in preview deployments, broken in production. No error — the variable is just undefined.
This happens when you add a variable to Vercel and leave the scope as "Development" or "Preview" only. Open Vercel → Project → Settings → Environment Variables, find the variable, and verify it has the Production checkbox enabled.
Failure 4: Stale NEXT_PUBLIC_ value after variable change
Symptom: You updated a NEXT_PUBLIC_ variable in Vercel but the old value is still showing up on the live site.
Cause: NEXT_PUBLIC_ variables are inlined at build time. Changing the variable in Vercel does not affect the currently deployed build. You must trigger a new deployment after changing any NEXT_PUBLIC_ variable. Use an empty commit if there's no code change:
git commit --allow-empty -m "chore: redeploy for env var update"
git push
Run through this before every deployment that adds or changes functionality:
□ Every new env var is in .env.example with a placeholder value
□ Every new env var is in your local .env with a real value
□ Every new env var is added to Vercel with the correct scope(s)
□ Client-accessible vars use NEXT_PUBLIC_ prefix — both in Vercel and in code
□ Server-only vars do NOT have NEXT_PUBLIC_ prefix
□ .env is in .gitignore — verify: git check-ignore -v .env
□ git status shows no .env files in the staged or unstaged list
□ After adding NEXT_PUBLIC_ vars: build locally to verify they resolve correctly
Verify .gitignore is working:
git check-ignore -v .env
# Should output: .gitignore:1:.env .env
# If it outputs nothing, .env is not being ignored — fix .gitignore immediately
Environment variables are configured correctly when:
git log --all --full-history -- .env returns no commits.env appears in .gitignore and git check-ignore -v .env confirms it.env.example exists with all required variable names and placeholder values — no real credentialsNEXT_PUBLIC_ prefix in both Vercel and code; server-only variables do notNEXT_PUBLIC_ var in a Client Component and both return the expected values in a production deploymentgit log --all -- .env returned commits at the start of this lesson, the exposed credential has been revoked at its source and a new credential is in place