D
Dev SOPKnowledge Base
Search
← All topics

Environment Variable Setup and Management for Node.js and Next.js

runnable

Environment variable setup, validation, and management for Next.js and Node.js projects — covering local dev, preview, and production environments with Zod validation.

envsecretsdeploymentconfigurationnextjs
Agent trigger phrases: environment variables · env setup · .env file · env validation · NEXT_PUBLIC · secrets management · env Zod

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.local and .env*.local must be in .gitignore — verify before every first commit
  • Never console.log() env vars — they end up in logs
  • Never pass entire process.env to client components
  • Rotate keys that were ever committed to git — assume compromised
  • Use z.string().startsWith("sk-") validation to catch swapped keys early