Manage env vars & secrets
Every app needs API keys, database URLs, and other secrets — and the #1 way vibe coders get burned is committing them to git or shipping them to the browser. This recipe is how you keep secrets out of your repo, typed and validated, and correctly wired into both local dev and production.
Think of config as three buckets: public client config (safe in the browser bundle), server-only secrets (must never reach the client), and build flags. Secrets live in a gitignored `.env` locally and in your platform's secret store in production — never in a committed file, ever. Commit a `.env.example` as the schema so teammates and AI agents know what's required, then validate everything at startup with a typed schema (Zod + t3-env) so a missing or typo'd var fails loudly on boot instead of silently deep in a request. Treat any secret that touches git history as already compromised.
Step by step
- 1
Sort every value into client vs server
Before writing config, label each value: is it safe for anyone in the world to see (a public app URL, a publishable Stripe key) or is it a secret (Stripe secret key, database URL, Resend key)? Secrets go server-only. This single distinction prevents the most damaging mistake — leaking a secret into the browser bundle.
- 2
Gitignore .env and commit .env.example
Put real values in `.env.local` (Next.js) or `.env` and add them to `.gitignore` immediately, before your first commit. Commit a `.env.example` with the same keys but blank or dummy values — it documents what the app needs without exposing anything. Run `git status` and confirm no `.env` file is staged.
- 3
Validate env at startup with a typed schema
Use `@t3-oss/env-nextjs` + Zod (or plain Zod on `process.env` for non-Next apps) to declare and parse every variable in one `env.ts`. Import `env` everywhere instead of raw `process.env`. A missing or malformed var now throws on boot with a clear message, and you get autocomplete + types for free.
- 4
Respect the public prefix rule
Only vars prefixed `NEXT_PUBLIC_` (Next.js) or `VITE_` (Vite) reach the browser — and everything with that prefix is public, baked into the JS bundle downloaded by every visitor. Never put a secret behind that prefix. Server secrets get no prefix and are only readable in server components, route handlers, and server actions.
- 5
Store production secrets in a real secret store
Production does not read your `.env` file. Set secrets in your platform's dashboard/CLI: `vercel env add`, Fly `fly secrets set`, or a dedicated manager like Doppler / Infisical / 1Password that injects them at runtime. Keep dev and prod keys separate (Stripe test vs live) so a leaked dev key can't move real money.
- 6
Have a rotation plan and a leak tripwire
If a secret ever lands in git — even in a deleted commit — rotate it immediately in the provider dashboard; assume it's compromised. Add a pre-commit guard like `gitleaks` or `git-secrets` so an accidental key never gets committed in the first place. Rotate on a schedule for anything high-value.
Starter snippet
// env.ts — one source of truth, validated at startup
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
// Server-only. Throws at boot if missing or malformed.
server: {
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
RESEND_API_KEY: z.string().min(1),
},
// Anything here is PUBLIC — shipped to the browser. No secrets.
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
// Next.js inlines client vars, so map them explicitly.
experimental__runtimeEnv: {
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
});
// Usage anywhere: import { env } from "@/env";
// env.STRIPE_SECRET_KEY ✅ typed, guaranteed present
// process.env.STRIPE_SECRET_KEY ❌ untyped, could be undefined✕ Watch out for
- Committing a .env file — deleting it later does nothing, because git history keeps the secret forever. Rotate the key, don't just `git rm`.
- Putting a secret behind NEXT_PUBLIC_ / VITE_ to 'make it work' on the client. That prefix bakes the value into the public bundle for every visitor to read.
- Assuming production reads your .env file. Most platforms don't — if you forget to set vars in the dashboard, the app boots with undefined secrets.
- No validation, so a missing or typo'd var doesn't fail on boot — it explodes deep inside a user's request with a cryptic 'undefined' error.
- Reusing one live API key across dev, staging, and prod, so a leaked laptop key exposes production data and real payments.
✓ Pro tips
- Tell your AI agent: 'When you add any new environment variable, add it to BOTH the Zod schema in env.ts AND .env.example in the same change, and never paste a real key value into any file — use a placeholder.' Then eyeball the diff for anything resembling a real secret before you commit.
- Add a pre-commit hook with gitleaks or git-secrets so an accidental `sk_live_...` gets blocked locally instead of reaching GitHub.
- Once you outgrow a single .env (multiple environments, a team, or CI), move to Doppler / Infisical / 1Password so secrets live in one managed place and get injected at runtime — no more copy-pasting keys into dashboards.
- The moment a secret hits a public place — git, a log, a screenshot, a Slack message — rotate it. Rotation is cheap; a breach is not.