Environment variables in Next.js are deceptively simple on the surface but hide important nuances that can lead to security issues or frustrating bugs. This guide covers everything from the basics to advanced patterns.
How Next.js Loads Environment Variables
Next.js automatically loads environment variables from several files, in this order of priority (later files override earlier ones):
.env # Base defaults (all environments)
.env.local # Local overrides (git-ignored)
.env.development # Development mode only
.env.development.local
.env.production # Production mode only
.env.production.local
.env.test # Test mode only
.env.test.local
Important: .env.local is always ignored during next build in test mode to ensure reproducible test results.
Which file should you use?
| File | Committed to git? | Use case |
|---|---|---|
.env | Yes | Default values, non-sensitive |
.env.local | No | Secrets, local overrides |
.env.development | Yes | Dev-specific defaults |
.env.production | Yes | Prod-specific defaults |
.env.*.local | No | Env-specific secrets |
A typical setup:
# .env (committed)
NEXT_PUBLIC_APP_URL=http://localhost:3000
LOG_LEVEL=debug
# .env.local (git-ignored, contains your secrets)
DATABASE_URL=postgres://user:password@localhost/mydb
STRIPE_SECRET_KEY=sk_test_...
The NEXT_PUBLIC_ Prefix: Understanding the Security Model
This is the most important concept to understand. Next.js has two types of environment variables:
Server-only variables (no prefix)
DATABASE_URL=postgres://...
STRIPE_SECRET_KEY=sk_live_...
JWT_SECRET=super-secret-key
These are only available on the server:
- Server Components
- API Routes
- Middleware
getServerSideProps/getStaticProps
Attempting to access them in client code returns undefined:
// app/page.tsx (Client Component)
'use client'
export default function Page() {
// Always undefined - this is intentional!
console.log(process.env.DATABASE_URL)
return <div>...</div>
}
Public variables (NEXT_PUBLIC_ prefix)
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_ANALYTICS_ID=G-XXXXXXX
These are inlined into your JavaScript bundle at build time:
// This works in both server and client code
const apiUrl = process.env.NEXT_PUBLIC_API_URL
Critical security implication: Anything with NEXT_PUBLIC_ is visible to anyone who views your page source. Never put secrets here.
The inlining mechanism
When Next.js builds your app, it literally replaces process.env.NEXT_PUBLIC_* with the actual values:
// Your code
const url = process.env.NEXT_PUBLIC_API_URL
// After build (in the browser bundle)
const url = "https://api.example.com"
This means:
- You can't change these values without rebuilding
- Dynamic access doesn't work:
process.env[varName]returns undefined - The values are frozen at build time
Runtime vs Build-time Variables
Understanding when variables are read is crucial for debugging:
| Variable type | When it's read | Can change without rebuild? |
|---|---|---|
| Server-only | Runtime | Yes |
| NEXT_PUBLIC_ | Build time | No |
Practical implications
Scenario: You update NEXT_PUBLIC_API_URL in Vercel and redeploy.
- If you just restarted the server: Old value (it was inlined at build)
- If you triggered a new build: New value
Scenario: You update DATABASE_URL in Vercel and redeploy.
- Server immediately uses the new value (read at runtime)
When you need runtime public variables
Sometimes you need client-accessible config that can change without rebuilding. Options:
1. Server-render the config:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<head>
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify({
apiUrl: process.env.API_URL,
})}`,
}}
/>
</head>
<body>{children}</body>
</html>
)
}
2. Use a config endpoint:
// app/api/config/route.ts
export async function GET() {
return Response.json({
apiUrl: process.env.API_URL,
features: process.env.FEATURE_FLAGS?.split(','),
})
}
3. Use Next.js publicRuntimeConfig (Pages Router only):
// next.config.js
module.exports = {
publicRuntimeConfig: {
apiUrl: process.env.API_URL,
},
}
Validating Environment Variables
Never trust that your environment variables exist or are valid. Validate them at startup.
Basic validation with Zod
// 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_'),
// Optional with default
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Public
NEXT_PUBLIC_APP_URL: z.string().url(),
})
export const env = envSchema.parse(process.env)
// TypeScript knows the exact shape
env.DATABASE_URL // string
env.LOG_LEVEL // 'debug' | 'info' | 'warn' | 'error'
Using @t3-oss/env-nextjs (recommended)
This library is specifically designed for Next.js and handles the client/server split:
// env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().min(1),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
})
Benefits:
- Build fails if variables are missing
- TypeScript autocompletion
- Runtime errors if you access server vars on client
- Clear documentation of required variables
Team Workflows: Sharing Secrets Safely
The classic problem: a new developer joins and needs the .env file.
Option 1: Manual sharing (not recommended)
The "just send it on Slack" approach. Problems:
- Secrets in chat history forever
- No access control after sharing
- No audit trail
- Version drift between team members
Option 2: Encrypted .env in repo
Tools like dotenvx or git-crypt encrypt your .env files:
# Encrypt with dotenvx
dotenvx encrypt
# Commit the encrypted file
git add .env.vault
git commit -m "Update secrets"
# Team member decrypts
dotenvx decrypt
Pros: Version controlled, simple Cons: Key management complexity, all-or-nothing access
Option 3: Password manager (1Password, Bitwarden)
Store secrets in a shared vault:
# 1Password CLI
op read "op://Vault/Project/.env" > .env.local
Pros: Familiar UX, access control Cons: Manual sync, no CI/CD integration
Option 4: Secrets managers (Doppler, Infisical, Keyway, etc.)
Dedicated tools for secrets management:
# Doppler
doppler secrets download --no-file --format env > .env.local
# Infisical
infisical export > .env.local
# Keyway
keyway pull
Pros: Access control, audit logs, CI/CD integration, sync to platforms Cons: Another tool to manage, potential vendor lock-in
Option 5: Platform-native (Vercel, Netlify, etc.)
Store secrets directly in your hosting platform:
# Vercel CLI
vercel env pull .env.local
Pros: No extra tools, integrated with deployments Cons: Tied to one platform, limited local dev workflow
The Deployment Sync Problem
One pain point that catches teams off guard: keeping environment variables in sync between your local setup and your hosting platform.
The typical failure scenario:
- You add
STRIPE_WEBHOOK_SECRETto.env.local - You update your code to use it
- You push to main
- Build passes (using old cached env vars)
- Production breaks because the variable doesn't exist on Vercel
Why this happens:
- Hosting platforms don't know about your local
.envchanges - There's no automatic sync between local and deployed environments
- Each platform has a different UI/CLI for managing variables
- No one remembers to update the dashboard after local changes
Solutions:
-
CLI-first workflow: Always update via CLI, not dashboard
# Vercel vercel env add STRIPE_WEBHOOK_SECRET production # Netlify netlify env:set STRIPE_WEBHOOK_SECRET "whsec_..." -
Pre-deploy checklist: Add env var verification to your PR template
-
Validation at build time: Use
@t3-oss/env-nextjsto fail the build if variables are missing -
Sync tools: Doppler, Infisical, and Keyway can push to multiple platforms from a single source of truth
The key insight: treat environment variables as part of your deployment, not an afterthought.
CI/CD Patterns
Your CI/CD pipeline needs access to secrets without committing them.
GitHub Actions with repository secrets
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: npm run build
env:
NEXT_PUBLIC_APP_URL: https://myapp.com
Using OIDC for cloud providers
Instead of storing cloud credentials as secrets, use OIDC:
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1
Generating .env file in CI
- name: Create .env file
run: |
cat << EOF > .env.local
DATABASE_URL=${{ secrets.DATABASE_URL }}
STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }}
EOF
- name: Build
run: npm run build
Common Mistakes and How to Avoid Them
1. Putting secrets in NEXT_PUBLIC_ variables
# WRONG - visible in browser
NEXT_PUBLIC_DATABASE_URL=postgres://user:pass@host/db
# RIGHT - server only
DATABASE_URL=postgres://user:pass@host/db
2. Dynamic access to env vars
// WRONG - doesn't work for NEXT_PUBLIC_ vars
const key = 'NEXT_PUBLIC_API_URL'
const value = process.env[key] // undefined
// RIGHT - direct access only
const value = process.env.NEXT_PUBLIC_API_URL
3. Forgetting to restart after changes
Environment variables are loaded when the dev server starts. After editing .env.local:
# Kill the server (Ctrl+C) and restart
npm run dev
4. Expecting .env.local in production builds
.env.local is for local development. In production:
- Use your hosting platform's env vars UI
- Or set them in your CI/CD pipeline
- Or use a secrets manager
5. Not documenting required variables
Always maintain a .env.example:
# .env.example (committed to git)
# Copy to .env.local and fill in values
# Required
DATABASE_URL=
STRIPE_SECRET_KEY=
# Optional (defaults shown)
LOG_LEVEL=info
Security Checklist
Before deploying:
- No secrets in
NEXT_PUBLIC_variables -
.env.localand.env*.localin.gitignore - Secrets not logged or exposed in error messages
- Different secrets for dev/staging/production
- Secrets rotated when team members leave
- Validation fails build if variables missing
-
.env.exampledocuments all required variables
Quick Reference
// Server Component - has access to all env vars
export default function Page() {
const dbUrl = process.env.DATABASE_URL // ✅ works
const apiUrl = process.env.NEXT_PUBLIC_API_URL // ✅ works
}
// Client Component - only NEXT_PUBLIC_
'use client'
export default function Button() {
const dbUrl = process.env.DATABASE_URL // ❌ undefined
const apiUrl = process.env.NEXT_PUBLIC_API_URL // ✅ works
}
// API Route - has access to all env vars
export async function GET() {
const dbUrl = process.env.DATABASE_URL // ✅ works
}