- I stopped guessing env vars. I validate them at boot.
- I generate
.env.exampleautomatically from Zod. - I fail fast in Next.js + Supabase setups.
- I use Cursor + Claude for refactors, not magic.
Context
I build small SaaS projects. Usually solo. Usually fast.
And env vars kept wasting my time.
Not the “what is an env var” part. The dumb part.
SUPABASE_URL missing. Or set to the anon key. Or NEXT_PUBLIC_ leaked into server-only code. Or a preview deploy where Vercel injected nothing and the app still booted… until the first real request.
I spent 4 hours on this once. Most of it was wrong.
So I made env vars boring.
I want one place to define them. One place to validate them. And a script that spits out .env.example so I don’t forget anything.
1) I write the env contract first. Then code.
I keep one file: src/env.ts.
Zod schema. Two groups.
Client-safe vars: must start with NEXT_PUBLIC_.
Server-only vars: never exposed.
This avoids the classic Next.js footgun: accidentally importing server env into a client component.
// src/env.ts
import { z } from "zod";
const serverSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
// Supabase server-side
SUPABASE_URL: z.string().url(),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(20),
// Optional but common
SENTRY_DSN: z.string().url().optional(),
});
const clientSchema = z.object({
// Only values safe to expose to the browser
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(20),
});
export function getServerEnv() {
const parsed = serverSchema.safeParse(process.env);
if (!parsed.success) {
// Make the error readable in CI logs
console.error("❌ Invalid server env", parsed.error.flatten().fieldErrors);
throw new Error("Invalid server env");
}
return parsed.data;
}
export function getClientEnv() {
const parsed = clientSchema.safeParse(process.env);
if (!parsed.success) {
console.error("❌ Invalid client env", parsed.error.flatten().fieldErrors);
throw new Error("Invalid client env");
}
return parsed.data;
}
Cursor + Claude helps here.
I’ll paste a messy .env and say: “turn this into a Zod schema with defaults and optional values.”
Then I review every field. Because models love adding “optional()” like candy.
2) I fail fast at boot. No more half-working deploys.
Next.js will happily start without your env vars.
Then you hit a route that calls Supabase.
Then you get something like:
TypeError: Failed to parse URL from undefined
Brutal. And it shows up late.
So I force env validation to run before anything else.
In the App Router, I do it in instrumentation.ts (runs on the server at startup in supported deployments). If I don’t want to rely on that, I also validate in a server-only module that every route hits.
Here’s the instrumentation.ts approach:
// src/instrumentation.ts
import { getServerEnv } from "./env";
export function register() {
// This throws during boot if env is wrong.
// That’s the whole point.
getServerEnv();
}
And I add this to next.config.ts setups that require it? Nope.
This file is auto-detected by Next.js when present.
One thing that bit me — local dev.
If you run next dev with a stale shell session, you’ll swear your .env.local changes aren’t applying. Restart the dev server. Every time.
3) I generate .env.example from the schema
I used to maintain .env.example by hand.
It was always wrong.
Missing one var. Or had an old name. Or documented the wrong prefix.
So I generate it.
This is boring Node.js. No frameworks.
It reads src/env.ts? Tempting. But parsing TS AST is overkill.
Instead, I keep keys in the schema and export them as arrays.
Yeah, duplication.
But it’s controlled duplication. And it’s still better than tribal knowledge.
// src/env-keys.ts
export const SERVER_ENV_KEYS = [
"NODE_ENV",
"SUPABASE_URL",
"SUPABASE_SERVICE_ROLE_KEY",
"SENTRY_DSN",
] as const;
export const CLIENT_ENV_KEYS = [
"NEXT_PUBLIC_SUPABASE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
] as const;
Then the generator:
// scripts/gen-env-example.ts
import { writeFileSync } from "node:fs";
import { CLIENT_ENV_KEYS, SERVER_ENV_KEYS } from "../src/env-keys";
function line(key: string) {
// Keep it empty. Don't leak real values.
return `${key}=`;
}
const header = [
"# Generated file. Don't edit by hand.",
"# Run: node --loader ts-node/esm scripts/gen-env-example.ts",
"",
].join("\n");
const body = [
"# Server-only",
...SERVER_ENV_KEYS.map(line),
"",
"# Client-safe",
...CLIENT_ENV_KEYS.map(line),
"",
].join("\n");
writeFileSync(".env.example", header + body, "utf8");
console.log("✅ Wrote .env.example");
If you don’t want ts-node, compile it.
I usually just make it plain JS in scripts/ and keep it dead simple.
Cursor + Claude is good at the annoying part here: turning a list of env vars into a consistent file format. Less good at “should this be public?” You decide that.
4) I prevent client/server env mixing
This is the sneaky bug.
You write a helper in src/lib/supabase.ts.
You import it from a client component.
Now your server env code is in the client bundle. Or Next yells with:
You're importing a component that needs "server-only"
I enforce separation with two modules.
One for server.
One for browser.
// src/lib/supabase/server.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";
import { getServerEnv } from "../../env";
export function supabaseServer() {
const env = getServerEnv();
return createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false },
});
}
// src/lib/supabase/browser.ts
import { createClient } from "@supabase/supabase-js";
import { getClientEnv } from "../../env";
export function supabaseBrowser() {
const env = getClientEnv();
return createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.NEXT_PUBLIC_SUPABASE_ANON_KEY);
}
And then I’m strict about imports.
Client components only import from browser.ts.
Server routes and server actions only import from server.ts.
I learned the hard way that “I’ll remember” isn’t a system.
5) My Cursor + Claude workflow for this stuff
I don’t prompt for “best practices”.
I prompt with constraints.
Like:
- “Convert these env names into Zod schema. Client vs server split.”
- “Make missing keys throw at boot. Next.js App Router.”
- “Write a script that generates
.env.examplefrom an exported key list.”
Then I do a pass for reality.
I look for:
- Are URL fields validated as
.url()? - Are secrets accidentally in
NEXT_PUBLIC_? - Did it add defaults that hide misconfig?
One time Claude defaulted SUPABASE_URL to "".
So validation passed.
Then Supabase client crashed later.
That’s the exact opposite of what I want.
Results
This turned env setup from “ritual” into “compile error energy”.
Before: I’d usually find env bugs after the first real request. Sometimes 10 minutes after deploy. Sometimes the next morning.
After: the app fails on boot if any required value is missing or malformed.
In the last 14 days, I hit 7 env-related issues locally (wrong key, missing NEXT_PUBLIC_ prefix, stale .env.local, wrong URL scheme). All 7 failed immediately with a readable list of fields. No more chasing undefined through stack traces.
Key takeaways
- Put env vars in a schema. One file. No exceptions.
- Validate on boot. Late failures waste hours.
- Split client vs server env modules so imports stay clean.
- Generate
.env.exampleso it doesn’t rot. - Use Cursor + Claude for refactors and boilerplate, then manually audit what’s public.
Closing
If you already validate env vars: where do you run the validation in Next.js App Router — instrumentation.ts, route-level imports, or something else that’s been more reliable in your deployments?
Top comments (0)