NNicheStackTutorials
← All tutorials
Static Hosting

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.

·14 min read·Static Hosting
Vercel environment variables debugging guide

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

  1. Add to .env.local for local dev
  2. Add to Vercel dashboard with all three environments checked (unless intentionally different)
  3. Add to .env.example (key name only, no value)
  4. Run vercel env pull to sync
  5. Push and wait for preview deploy
  6. Hit preview URL and test the feature
  7. Only then merge to main

What the docs don't tell you

Docs sayReality
"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.

Tags: VercelNext.jsEnvironment VariablesDebugging