Overview
Structured environment variable management for Next.js projects. Covers naming conventions, validation at startup, and multi-environment patterns.
File Hierarchy (Next.js)
Files are loaded in this priority order (later overrides earlier):
.env # Defaults — committed to git
.env.local # Local overrides — NEVER committed
.env.development # Dev-specific — committed
.env.development.local # Dev local — NEVER committed
.env.production # Production values — committed (non-secrets only)
.env.production.local # Production local — NEVER committed
Rule: Never put actual secrets in files that are committed. .local files stay off git.
NEXT_PUBLIC_ Prefix
Variables prefixed NEXT_PUBLIC_ are inlined into the client bundle at build time. Everything else is server-only.
# Server-only (safe for secrets)
DATABASE_URL=postgresql://...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
ANTHROPIC_API_KEY=sk-ant-...
# Client-accessible (never put secrets here — visible in browser)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
NEXT_PUBLIC_APP_URL=https://myapp.com
Zod Validation at Startup (Recommended Pattern)
Fail fast if required env vars are missing or malformed:
// src/env.ts
import { z } from "zod";
const serverSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
ANTHROPIC_API_KEY: z.string().startsWith("sk-ant-"),
OPENAI_API_KEY: z.string().startsWith("sk-"),
});
const clientSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().url(),
});
// Validate server env (runs only on server)
export const env = serverSchema.parse({
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
});
// Validate client env (runs on both)
export const clientEnv = clientSchema.parse({
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
});
If validation fails at startup, the app crashes with a clear error listing exactly which variable is missing — not a cryptic runtime failure.
.env.example Template
Always maintain .env.example with all variables documented but values removed:
# Database
DATABASE_URL=postgresql://user:password@host:5432/dbname
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxxxxxxxxxxxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... (get from supabase.com dashboard)
SUPABASE_SERVICE_ROLE_KEY=eyJ... (service role — keep secret)
# Authentication (Clerk)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
# AI
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
Commit .env.example. New devs clone + copy this to .env.local, fill in values.
Vercel Environment Setup
# Add to production only
vercel env add SUPABASE_SERVICE_ROLE_KEY production
# Add to all environments
vercel env add NEXT_PUBLIC_APP_URL production preview development
# Pull production env to local
vercel env pull .env.local
# List all env vars
vercel env ls
Multi-Environment Pattern
// src/lib/config.ts
const configs = {
development: {
apiUrl: "http://localhost:3001",
debug: true,
logLevel: "debug" as const,
},
production: {
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
debug: false,
logLevel: "error" as const,
},
};
export const config = configs[process.env.NODE_ENV === "production" ? "production" : "development"];
Security Rules
.env.localand.env*.localmust be in.gitignore— verify before every first commit- Never
console.log()env vars — they end up in logs - Never pass entire
process.envto client components - Rotate keys that were ever committed to git — assume compromised
- Use
z.string().startsWith("sk-")validation to catch swapped keys early