Vercel Env Vars Broke My Preview Deploy (And the 3 Fixes That Actually Worked)
Real debugging story: Next.js 15 app worked locally, preview returned 500, production was fine. The env var scoping mistakes Vercel docs don't emphasize.
Tested on: Next.js 15.3, Vercel CLI 41.x, Node 22
Scenario: Blog with Supabase backend. npm run dev worked. Preview deploy on PR #14 returned 500 on /api/posts. Production on main was mysteriously fine.
This isn't a "how env vars work" overview. This is the exact debugging path I followed.
What broke
Pushed a branch, opened a PR. Vercel bot commented with a preview URL. Clicked it. Homepage loaded. Clicked "Posts" — white screen, then:
GET https://my-blog-preview-abc123.vercel.app/api/posts 500 (Internal Server Error)
Vercel function logs showed:
Error: Missing required environment variable: SUPABASE_SERVICE_ROLE_KEY
at required (src/lib/env.ts:5:11)
at <module> (src/lib/env.ts:12:24)
But I'd added SUPABASE_SERVICE_ROLE_KEY in the Vercel dashboard two days ago for production. That's the trap.
Mistake #1: Env vars are scoped per environment
In Vercel → Settings → Environment Variables, each variable has checkboxes:
- Production
- Preview
- Development
I only checked Production when I first added the key. Preview deploys don't inherit production env vars.
Fix: Open the variable → edit → check Preview and Development too → Save → Redeploy.
# After fix, verify what's set for preview:
$ vercel env ls
name value environments
SUPABASE_SERVICE_ROLE_KEY Encrypted Production, Preview, Development
SUPABASE_URL Encrypted Production, Preview, Development
NEXT_PUBLIC_SUPABASE_ANON_KEY Encrypted Production, Preview, Development
Gotcha: Saving in the dashboard does NOT update running deployments. You must trigger a new deploy. I lost 40 minutes before realizing this.
Mistake #2: Build-time crash vs runtime crash
My env.ts validated variables at module load time:
// src/lib/env.ts — this crashes the BUILD if vars are missing
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const env = {
supabaseUrl: required("SUPABASE_URL"),
serviceRoleKey: required("SUPABASE_SERVICE_ROLE_KEY"),
};
When preview was missing the key, the entire serverless function failed to initialize — not just the API route, but any import of env.ts.
Better pattern — validate lazily, return useful errors:
// src/lib/env.ts
function get(name: string): string | undefined {
return process.env[name];
}
export function getSupabaseConfig() {
const url = get("SUPABASE_URL");
const key = get("SUPABASE_SERVICE_ROLE_KEY");
if (!url || !key) {
throw new Error(
`Supabase not configured. Missing: ${[
!url && "SUPABASE_URL",
!key && "SUPABASE_SERVICE_ROLE_KEY",
].filter(Boolean).join(", ")}. ` +
`Check Vercel env vars are set for Preview environment.`
);
}
return { url, key };
}
Now the API route returns a 503 with a clear message instead of a silent 500:
// src/app/api/posts/route.ts
import { getSupabaseConfig } from "@/lib/env";
import { createClient } from "@supabase/supabase-js";
export async function GET() {
try {
const { url, key } = getSupabaseConfig();
const supabase = createClient(url, key);
const { data, error } = await supabase.from("posts").select("*").limit(20);
if (error) return Response.json({ error: error.message }, { status: 500 });
return Response.json(data);
} catch (e) {
const message = e instanceof Error ? e.message : "Config error";
return Response.json({ error: message }, { status: 503 });
}
}
Mistake #3: NEXT_PUBLIC_ on the wrong variable
I initially named my anon key NEXT_PUBLIC_SUPABASE_KEY and put the service role key in a server-only var. Good so far.
Then I refactored and accidentally renamed:
# .env.local — DON'T DO THIS
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIs...
NEXT_PUBLIC_ vars get inlined into the client JavaScript bundle. Anyone can open DevTools → Sources and read your service role key.
How to check if you leaked a secret:
# After build, search the client bundle
$ npm run build
$ rg "eyJhbGci" .next/static/chunks/
# If this returns matches, your secret is in the client bundle. Rotate the key immediately.
Rule: only NEXT_PUBLIC_ for things that are already public (anon keys, GA IDs, public API URLs). Service role keys, Stripe secrets, database URLs — never prefix with NEXT_PUBLIC_.
Local dev setup that matches Vercel
# Pull remote env vars (creates .env.local)
$ vercel link
$ vercel env pull .env.local
# .env.local (auto-generated, do not commit)
SUPABASE_URL="https://xxxxx.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="eyJhbGci..."
NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGci..."
.env*
!.env.example
Commit an .env.example so collaborators know what's needed:
# .env.example
SUPABASE_URL=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
Verify before merging
# 1. Production build locally with pulled env
$ vercel env pull .env.local
$ npm run build
$ npm start
# Hit http://localhost:3000/api/posts — should return JSON
# 2. Check preview deploy logs after push
$ vercel logs <preview-deployment-url>
Temporary debug endpoint (delete before launch):
// src/app/api/_env-check/route.ts
export async function GET() {
return Response.json({
nodeEnv: process.env.NODE_ENV,
vercelEnv: process.env.VERCEL_ENV, // "production" | "preview" | "development"
hasSupabaseUrl: !!process.env.SUPABASE_URL,
hasServiceKey: !!process.env.SUPABASE_SERVICE_ROLE_KEY,
// Never log actual values
});
}
VERCEL_ENV is injected automatically by Vercel. Useful to confirm which environment you're actually running in.
Checklist I now use for every new env var
- Add to
.env.localfor local dev - Add to Vercel dashboard with all three environments checked (unless intentionally different)
- Add to
.env.example(key name only, no value) - Run
vercel env pullto sync - Push and wait for preview deploy
- Hit preview URL and test the feature
- Only then merge to main
What the docs don't tell you
| Docs say | Reality |
|---|---|
| "Add env vars in Settings" | You must also select Preview/Development checkboxes |
| "Redeploy to apply changes" | This means re-trigger deploy, not just save |
"NEXT_PUBLIC_ for client" | Easy to accidentally expose secrets — audit your bundle |
"Use .env.local" | vercel env pull is faster for team sync |
The whole incident took about 2 hours. 90% was env scoping, 10% was module-level validation crashing early. Both are preventable with the checklist above.