DEV Community

Sathish
Sathish

Posted on

Cursor + Claude: stop shipping broken env vars

  • I stopped guessing env vars. I validate them at boot.
  • I generate .env.example automatically 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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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 },
  });
}
Enter fullscreen mode Exit fullscreen mode
// 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);
}
Enter fullscreen mode Exit fullscreen mode

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.example from 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.example so 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)