Set up environment variable validation: $ARGUMENTS
$ARGUMENTS can include:
- "audit" — scan existing codebase for unvalidated env vars and fix
- "nextjs" or "vite" — framework hint (auto-detected)
- "full" — generate .env.example + Zod schema + check script (default)
- Empty — full setup, auto-detect framework
Before Starting
- Read
package.jsonto detect framework and see existing scripts - Check if
src/lib/env.tsalready exists — update rather than overwrite - Read
.env.exampleor.env.local.exampleif present - Grep for
process.env.andimport.meta.env.— find all usages to catalog vars - Read
src/lib/config/tenant.config.tsfor tenant-specific vars
Architecture
src/lib/env.ts ← Zod-validated env schema — single source of truth
.env.example ← All vars with empty values + comments — committed to git
.env.local ← Real values — in .gitignore, never committed
scripts/check-env.ts ← CLI script: validates env before build/deploy
Step 1 — Audit Existing Env Var Usage
Run these before writing anything new:
# Find all process.env usages
grep -rn "process\.env\." src/ --include="*.ts" --include="*.tsx" | grep -v ".test." | sort -u
# Find all import.meta.env usages (Vite)
grep -rn "import\.meta\.env\." src/ --include="*.ts" --include="*.tsx" | sort -u
Catalog every var found — this drives what goes in the schema.
Step 2 — Zod Env Schema (Next.js 15)
Create or update src/lib/env.ts:
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/**
* Server-side environment variables.
* Available in Server Components, API Routes, and server actions.
* NEVER accessible in the browser.
*/
server: {
// Database
DATABASE_URL: z.string().url(),
// Supabase (server-only)
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1).optional(),
// Auth
// (none needed — Supabase handles auth keys)
// Tenant
TENANT_ORG_ID: z.string().uuid(),
// Email (Resend)
RESEND_API_KEY: z.string().startsWith("re_").optional(),
RESEND_FROM_EMAIL: z.string().email().optional(),
RESEND_FROM_NAME: z.string().min(1).optional(),
// Payments (Stripe)
STRIPE_SECRET_KEY: z.string().startsWith("sk_").optional(),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_").optional(),
// Monitoring (Sentry)
SENTRY_DSN: z.string().url().optional(),
SENTRY_ORG: z.string().optional(),
SENTRY_PROJECT: z.string().optional(),
SENTRY_AUTH_TOKEN: z.string().optional(),
// AI
OPENAI_API_KEY: z.string().startsWith("sk-").optional(),
},
/**
* Client-side environment variables.
* Must be prefixed with NEXT_PUBLIC_.
* These are bundled into the browser build — treat as public.
*/
client: {
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
// Payments
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith("pk_").optional(),
// Analytics
NEXT_PUBLIC_GA4_MEASUREMENT_ID: z.string().optional(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().url().optional(),
},
/**
* Destructuring map — Next.js requires explicit mapping for process.env
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
TENANT_ORG_ID: process.env.TENANT_ORG_ID,
RESEND_API_KEY: process.env.RESEND_API_KEY,
RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL,
RESEND_FROM_NAME: process.env.RESEND_FROM_NAME,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
SENTRY_DSN: process.env.SENTRY_DSN,
SENTRY_ORG: process.env.SENTRY_ORG,
SENTRY_PROJECT: process.env.SENTRY_PROJECT,
SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
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,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
NEXT_PUBLIC_GA4_MEASUREMENT_ID: process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
},
/**
* Skip env validation in CI builds that don't need all vars.
* Never skip in production.
*/
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
});
Install: pnpm add @t3-oss/env-nextjs
Step 3 — Zod Env Schema (Vite)
For Vite projects, use @t3-oss/env-core instead:
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const env = createEnv({
clientPrefix: "VITE_",
server: {
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_").optional(),
// ... same pattern
},
client: {
VITE_SUPABASE_URL: z.string().url(),
VITE_SUPABASE_ANON_KEY: z.string().min(1),
// ...
},
runtimeEnv: import.meta.env,
skipValidation: import.meta.env.CI === "true",
});
Install: pnpm add @t3-oss/env-core
Step 4 — .env.example
Create .env.example with every variable, empty values, and inline comments:
# =============================================================================
# REQUIRED — app will not start without these
# =============================================================================
# Database (Supabase connection string from Project Settings → Database → URI)
DATABASE_URL=postgresql://postgres:[password]@db.[project-ref].supabase.co:5432/postgres
# Supabase (Project Settings → API)
NEXT_PUBLIC_SUPABASE_URL=https://[project-ref].supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
# Tenant (your organization's UUID from the organizations table)
TENANT_ORG_ID=
# App URL (no trailing slash)
NEXT_PUBLIC_APP_URL=http://localhost:3000
# =============================================================================
# OPTIONAL — features degrade gracefully without these
# =============================================================================
# Supabase service role (server-only, for admin operations)
# SUPABASE_SERVICE_ROLE_KEY=eyJ...
# Email (Resend — resend.com/api-keys)
# RESEND_API_KEY=re_...
# RESEND_FROM_EMAIL=hello@yourdomain.com
# RESEND_FROM_NAME=Your App
# Payments (Stripe — dashboard.stripe.com/apikeys)
# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_WEBHOOK_SECRET=whsec_...
# Analytics
# NEXT_PUBLIC_GA4_MEASUREMENT_ID=G-...
# NEXT_PUBLIC_POSTHOG_KEY=phc_...
# NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
# Error Monitoring (Sentry)
# SENTRY_DSN=https://...@sentry.io/...
# NEXT_PUBLIC_SENTRY_DSN= (same as above, for client-side)
# AI
# OPENAI_API_KEY=sk-...
Step 5 — check:env Script
Create scripts/check-env.ts:
#!/usr/bin/env tsx
/**
* Validates all required environment variables are set.
* Run before deploy: pnpm check:env
*/
import { z } from "zod";
const REQUIRED = z.object({
DATABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
TENANT_ORG_ID: z.string().uuid(),
});
const result = REQUIRED.safeParse(process.env);
if (!result.success) {
console.error("❌ Missing or invalid environment variables:\n");
result.error.errors.forEach((err) => {
console.error(` ${err.path.join(".")}: ${err.message}`);
});
console.error("\nSee .env.example for the full list of required variables.");
process.exit(1);
}
console.log("✅ All required environment variables are set.");
Add to package.json:
{
"scripts": {
"check:env": "tsx scripts/check-env.ts"
}
}
Install: pnpm add -D tsx
Step 6 — .gitignore Verification
Ensure these lines are in .gitignore:
.env
.env.local
.env.*.local
# But DO commit:
# .env.example ← intentionally not in .gitignore
Step 7 — Import Pattern
Replace all raw process.env.X with env.X in the codebase:
// Before (unvalidated, no type safety)
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
// After (Zod-validated, typed)
import { env } from "@/lib/env";
const url = env.NEXT_PUBLIC_SUPABASE_URL;
Verify
pnpm check:env— passes with current.env.local- Remove a required var from
.env.local, runpnpm check:env— should fail with clear error pnpm typecheck— env.ts has no errorspnpm build— build succeeds (or fails fast with clear env error if var is missing)
Anti-Patterns
- NEVER commit
.env.localor.env— only.env.example - NEVER use
!non-null assertions onprocess.env.X— use Zod schema instead - NEVER put secrets in client-side vars (
NEXT_PUBLIC_prefix) - NEVER skip validation with
SKIP_ENV_VALIDATION=truein production - NEVER hardcode URLs or API keys in source files — always use env vars