Environment variables keep configuration and secrets out of your codebase. Used well, they make it easy to switch between local, staging, and production without hardcoding values or committing secrets.
What belongs in environment variables
| Store in env vars | Do not store |
|---|---|
| API keys | Non-secret defaults |
| Database URLs | Values that never change |
| OAuth secrets | Large blobs of data |
| Feature flags | Complex objects |
| External service URLs | |
| Encryption keys |
Rule: if it is secret or changes per environment, use an env var.
File organization
Typical setup
project/
.env # Defaults, committed (no secrets)
.env.local # Local overrides, gitignored
.env.development # Dev-specific, committed
.env.production # Prod-specific, committed
.env.example # Template with dummy values
.gitignore # Includes .env.local
.gitignore
# Environment files with secrets
.env.local
.env.*.local
# Keep .env.example
!.env.example
.env.example
Document required variables:
# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/dbname
# Authentication
AUTH_SECRET=generate-with-openssl-rand-base64-32
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
# External services
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# Optional
ENABLE_ANALYTICS=false
Framework-specific patterns
Next.js
# Server-only (default)
DATABASE_URL=postgres://...
API_SECRET=xxx
# Client-exposed (NEXT_PUBLIC_ prefix)
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_APP_NAME=My App
Load order:
.env(all environments).env.local(all environments, gitignored).env.developmentor.env.production.env.development.localor.env.production.local
// Server Component or Route Handler
const secret = process.env.API_SECRET; // Works
// Client Component
const apiUrl = process.env.NEXT_PUBLIC_API_URL; // Works
const secret = process.env.API_SECRET; // undefined!
Vite
# Server-only
DATABASE_URL=postgres://...
# Client-exposed (VITE_ prefix)
VITE_API_URL=https://api.example.com
VITE_APP_NAME=My App
// In client code
const apiUrl = import.meta.env.VITE_API_URL;
const mode = import.meta.env.MODE; // 'development' or 'production'
Node.js
# Load with dotenv
npm install dotenv
// At the top of your entry file
import 'dotenv/config';
// Or Node 20+ with --env-file flag
// node --env-file=.env app.js
const dbUrl = process.env.DATABASE_URL;
Validation at startup
Catch missing variables early:
// lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(32),
GOOGLE_CLIENT_ID: z.string(),
GOOGLE_CLIENT_SECRET: z.string(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});
function validateEnv() {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:');
console.error(result.error.format());
process.exit(1);
}
return result.data;
}
export const env = validateEnv();
Usage:
import { env } from '@/lib/env';
const db = createConnection(env.DATABASE_URL);
Type-safe access
// lib/env.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
AUTH_SECRET: string;
NEXT_PUBLIC_API_URL: string;
}
}
}
export {};
Security rules
Do
- Store secrets in env vars, not code
- Use
.env.localfor local secrets - Validate required variables at startup
- Rotate secrets if exposed
- Use different values per environment
Do not
- Commit real secrets to git
- Log full env var contents
- Expose server secrets with
NEXT_PUBLIC_orVITE_ - Share secrets via insecure channels
- Use the same secrets for dev and prod
Checking for exposed secrets
// Prevent accidental exposure
if (typeof window !== 'undefined') {
// This is client-side
const serverVar = process.env.API_SECRET;
// serverVar will be undefined, which is correct
}
Deployment patterns
Vercel
# CLI
vercel env add DATABASE_URL production
vercel env pull .env.local
# Or use dashboard:
# Project Settings > Environment Variables
GitHub Actions
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
steps:
- run: npm run build
Set secrets in: Repository Settings > Secrets and variables > Actions
Docker
# Build-time variables
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Runtime variables (docker run -e or docker-compose)
# docker-compose.yml
services:
app:
environment:
- DATABASE_URL=${DATABASE_URL}
env_file:
- .env.local
Common patterns
Feature flags
ENABLE_NEW_CHECKOUT=true
ENABLE_ANALYTICS=false
const isNewCheckout = process.env.ENABLE_NEW_CHECKOUT === 'true';
URL configuration
# Base URLs
API_URL=https://api.example.com
CDN_URL=https://cdn.example.com
# With paths
WEBHOOK_URL=${API_URL}/webhooks/stripe
Database URLs
# Development
DATABASE_URL=postgresql://user:password@localhost:5432/myapp_dev
# Production (use connection pooling)
DATABASE_URL=postgresql://user:password@db.example.com:5432/myapp?sslmode=require
Debugging
Check loaded variables
// Development only!
if (process.env.NODE_ENV === 'development') {
console.log('Loaded env vars:', {
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL ? '[set]' : '[missing]',
API_SECRET: process.env.API_SECRET ? '[set]' : '[missing]',
});
}
Common issues
| Problem | Cause | Fix |
|---|---|---|
| Variable undefined | Not loaded | Check file location and name |
| NEXT_PUBLIC_ undefined client-side | Build-time not available | Rebuild after adding |
| Works locally, fails in prod | Not set in deployment | Add to deployment platform |
| Wrong value | Override order | Check .env.local precedence |
Summary
Environment variables best practices:
- Separate secrets from code
- Use appropriate prefixes (NEXT_PUBLIC_, VITE_)
- Validate at startup with schema
- Document in .env.example
- Never commit secrets to git
- Set per environment in deployment platform
This keeps your configuration secure and your deployments predictable.
