Works locally, breaks in prod. Your .env has NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY, createClient(url, key) is happy, then you deploy to Vercel / Netlify / Cloudflare Pages and the browser console throws:
Error: supabaseUrl is required.
TypeError: Cannot read properties of undefined (reading 'auth')
Or it fails silently: every Supabase call returns nothing, the page renders but is empty.
Fastest fix (90% of cases): the deploy platform never received the env vars. Add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in your host’s dashboard, tick all environments (Production + Preview + Development), then redeploy — because NEXT_PUBLIC_* values are baked into the JS bundle at build time, a dashboard change does nothing until a new build runs.
Mental model: env vars do not auto-sync from .env to the cloud. Each platform needs them configured manually, and any value the browser must read needs a framework prefix (NEXT_PUBLIC_ / VITE_ / PUBLIC_). Without the prefix it is stripped from the client bundle and reads undefined.
Which bucket are you in?
Match your exact symptom to the cause before touching anything.
| Symptom / what you observe | Most likely cause | Jump to |
|---|---|---|
| Dashboard env list is empty | Platform never got the vars | Cause 1 |
Var exists in dashboard, still undefined in browser | Wrong/missing prefix, or never redeployed | Causes 2, 3 |
| Works on preview/branch URL, broken on prod domain | Var scoped to Preview only | Cause 4 |
process.env.SUPABASE_SERVICE_ROLE_KEY is undefined in a component | Server-only key read on the client | Cause 5 |
Invalid URL / supabaseUrl is required despite a value being set | Trailing space/newline in the value | Cause 6 |
| Worked for months, broke this week with a 401 | Legacy anon key disabled mid-migration | Cause 7 |
Common causes
Ordered by hit rate, highest first.
1. The platform never got these env vars
Most common by far. You have a local .env, but the Vercel/Netlify/Cloudflare dashboard has nothing. .env is git-ignored (and should be), so after deploy process.env.X === undefined.
How to spot it: open Vercel / Netlify / Cloudflare Pages → Environment Variables → are the two names listed at all?
2. Prefix mismatch with the framework
Only prefixed names are inlined into the client bundle. Mismatch the prefix and the value vanishes from the browser.
Next.js → NEXT_PUBLIC_X (process.env.NEXT_PUBLIC_X)
Vite → VITE_X (import.meta.env.VITE_X)
Astro → PUBLIC_X (import.meta.env.PUBLIC_X)
SvelteKit → PUBLIC_X (import { PUBLIC_X } from '$env/static/public')
Remix → no enforced prefix; expose explicitly via the loader / window.ENV
How to spot it: is every client-side reference using the prefixed name? A bare process.env.SUPABASE_URL in a 'use client' component is always undefined.
3. The deployment was built before the var existed
This is the one that wastes the most time. NEXT_PUBLIC_* / VITE_* / PUBLIC_* values are inlined at build time, not read at runtime. If you add the var and only the dashboard updates, the already-built bundle still has undefined literally compiled in. You must trigger a fresh build.
Note: Vercel’s build cache does not block env updates — the cache stores node_modules and framework artifacts, and every deploy receives the latest env values. The trap is deploying a commit that was built before the var was added. A clean redeploy resolves both.
How to spot it: you just added the var, the dashboard shows it, but the live bundle is unchanged because no new build ran.
4. Set in Preview only, not Production
Vercel env scopes are Development / Preview / Production. Tick only Preview and your production domain stays undefined. Variables never propagate across scopes automatically.
How to spot it: the branch/preview URL works, the production domain does not.
5. Trying to read a server-only env on the client
SUPABASE_SERVICE_ROLE_KEY / sb_secret_... has no public prefix, so it is undefined in the browser — and that is correct. The secret/service-role key has full database power and bypasses Row Level Security; it must never reach the client.
How to spot it: you are reading a non-prefixed key inside a client component. Move that code to a server route, Server Action, or Edge Function.
6. Trailing whitespace or newline in the value
A copy-pasted key picked up a space or \n. The URL parse then fails on init with Invalid URL even though “the value is set.”
How to spot it: trimming the value in the dashboard fixes it. Re-paste from Supabase carefully.
7. Legacy anon key disabled mid-migration (new in 2026)
As of June 2026 Supabase is rolling out a new key system and legacy anon / service_role JWT keys are slated for deprecation by the end of 2026. New projects ship sb_publishable_... (client-safe, replaces anon) and sb_secret_... (server-only, replaces service_role). If a teammate disabled the legacy keys in the dashboard, every request using the old anon key starts returning 401/Invalid API key even though the env var is still “present.”
How to spot it: it worked for months, then broke after a key rotation, with auth errors rather than undefined. See Step 8.
Shortest path to fix
Step 1: Copy correct values from Supabase
Supabase split the keys page out of the old API settings in 2026, so the menu path is:
Supabase Dashboard → Project → Settings → API
→ Project URL (https://<ref>.supabase.co)
Supabase Dashboard → Project → Settings → API Keys
→ Publishable key (sb_publishable_...) ← use this for the browser
→ Legacy API Keys tab → anon public (eyJ...) ← if you have not migrated yet
Both key types work simultaneously, so you can migrate one client at a time. Do not pick up leading/trailing whitespace when copying.
Step 2: Add the env on the deploy platform
Vercel:
Project → Settings → Environment Variables
→ Key: NEXT_PUBLIC_SUPABASE_URL Value: https://<ref>.supabase.co
→ Key: NEXT_PUBLIC_SUPABASE_ANON_KEY Value: sb_publishable_... (or eyJ... legacy)
→ Environments: tick Production + Preview + Development
Netlify:
Site configuration → Environment variables → Add a variable
→ set "Deploy contexts" to All
Cloudflare Pages:
Settings → Variables and Secrets → Production (and Preview)
→ variables read at build time must be set here so VITE_/NEXT_PUBLIC_ get inlined
Confirm all environments are ticked. On Cloudflare, client-prefixed vars must exist at build time, not just runtime, or they are never inlined.
Step 3: Grep the code for the right prefix
# Next.js — every client reference must be the NEXT_PUBLIC_ name
grep -rn "process.env.*SUPABASE" src/
# Vite
grep -rn "import.meta.env.*SUPABASE" src/ # expect VITE_SUPABASE_*
# Astro
grep -rn "import.meta.env.*SUPABASE" src/ # expect PUBLIC_SUPABASE_*
Fix any that do not match:
// Next.js
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
Step 4: Force a fresh redeploy
A dashboard change alone does nothing — you must rebuild so the new value is inlined.
Vercel:
Deployments → latest → "..." menu → Redeploy
→ uncheck "Use existing Build Cache" (forces a clean rebuild)
Netlify:
Deploys → Trigger deploy → "Clear cache and deploy site"
Cloudflare Pages:
Deployments → latest → "Retry deployment" (rebuilds with current variables)
Step 5: Verify on the live site
Open the production URL, open DevTools → Console. Because the value is inlined, the variable is literally present in the shipped bundle:
// Next.js
console.log(process.env.NEXT_PUBLIC_SUPABASE_URL);
// → "https://<ref>.supabase.co", not undefined
// Vite
console.log(import.meta.env.VITE_SUPABASE_URL);
You can also confirm a live request reaches Supabase:
const { error } = await supabase.from('any_table').select('*').limit(1);
console.log(error ?? 'reached Supabase');
Still undefined → wrong prefix (Step 3) or the build cache was not cleared (Step 4). 401 / Invalid API key → the key itself, not the env wiring (Step 8).
Step 6: Add boot-time validation
Make the next missing var fail the build instead of failing a user at runtime:
// lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(20),
});
export const env = envSchema.parse({
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
});
A build that fails on a missing env is far cheaper than a production page that silently renders blank.
Step 7: Keep server-only keys distinct
// ✅ Client-visible (low privilege, safe to ship)
NEXT_PUBLIC_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY // or sb_publishable_...
// ❌ Server only — never add a NEXT_PUBLIC_ / VITE_ / PUBLIC_ prefix
SUPABASE_SERVICE_ROLE_KEY // or sb_secret_... full DB power, bypasses RLS
If a secret/service-role key ever lands in a client bundle, treat it as compromised: rotate it under Settings → API Keys immediately.
Step 8: If you are mid key-migration
If requests fail with 401 / Invalid API key rather than undefined, the env wiring is fine and the key is the problem. Move to the publishable key and update both the env and any hardcoded fallback:
NEXT_PUBLIC_SUPABASE_ANON_KEY = sb_publishable_... # replaces eyJ... anon
SUPABASE_SECRET_KEY = sb_secret_... # replaces service_role
Swap one client at a time — both key types stay valid until you deactivate the legacy keys, so there is no flag-day cutover. Only disable the legacy keys after nothing depends on them.
Prevention
- List every required env (with its prefix) in the README or
CLAUDE.md. - Keep a committed
.env.example(no real values) in sync with.envso new contributors copy from it. - Validate env with zod at boot; fail fast on anything missing.
- Any name with
_KEY,_SECRET,_TOKEN, orsb_secret_must never carry aNEXT_PUBLIC_/VITE_/PUBLIC_prefix. - When you add a new env, set it in Local + Preview + Production in the same sitting.
- After any env change, default to “Clear cache and redeploy” — never assume an incremental build picks up new values.
- Add env changes to the PR template or deploy checklist.
- Track the Supabase key migration: plan to be off legacy
anon/service_rolebefore the end-of-2026 deprecation.
FAQ
Why does it work locally but not in production?
Your local process reads .env directly; the cloud build does not. Each platform needs the vars configured in its own dashboard, and the value is then inlined into the bundle at build time. No dashboard entry, no value.
I added the var on Vercel but it is still undefined. Why?
Because NEXT_PUBLIC_* is compiled into the JS at build time, the deployment that was built before you added the var still ships undefined. Redeploy (Step 4) so a fresh build inlines the new value.
Should I use the anon key or the new publishable key?
Either works today. As of June 2026 the legacy anon/service_role JWT keys are scheduled for deprecation by the end of 2026, so for new projects prefer sb_publishable_... (client) and sb_secret_... (server). Both key types are valid simultaneously, so you can migrate gradually.
Is it safe to expose the anon / publishable key in the browser?
Yes. It is low-privilege and designed to ship in client code; your Row Level Security policies are what actually protect the data. The service-role / sb_secret_... key is the dangerous one and must stay server-side.
Where do I find the keys now? The old API page changed.
Project URL is still under Settings → API. The keys moved to a dedicated Settings → API Keys page in 2026, with legacy anon/service_role under the Legacy API Keys tab.
It worked for months and suddenly returns 401. That is not an env-wiring issue — it is the key. A legacy key was likely rotated or disabled during the migration. Switch the env to the publishable/secret keys (Step 8).