本地好好的,一上线就崩。你的 .env 里有 NEXT_PUBLIC_SUPABASE_URL 和 NEXT_PUBLIC_SUPABASE_ANON_KEY,createClient(url, key) 一切正常,部署到 Vercel / Netlify / Cloudflare Pages 后,浏览器 console 报:
Error: supabaseUrl is required.
TypeError: Cannot read properties of undefined (reading 'auth')
或者更安静——所有 Supabase 调用静默失败,页面能渲染但没数据。
最快的修复(90% 的情况): 部署平台压根没拿到这两个 env。到宿主后台加上 NEXT_PUBLIC_SUPABASE_URL 和 NEXT_PUBLIC_SUPABASE_ANON_KEY,把所有环境(Production + Preview + Development)都勾上,然后重新部署——因为 NEXT_PUBLIC_* 的值是在 build 时被写死进 JS bundle 的,光在后台改不触发新 build 就毫无作用。
理解关键:env vars 不会「自动从 .env 同步到云」。每个平台都要单独配置;任何需要让浏览器读到的值都得带框架前缀(NEXT_PUBLIC_ / VITE_ / PUBLIC_),少了前缀就会被从 client bundle 里剥掉,读出来是 undefined。
先定位你是哪种情况
动手前先把你看到的具体现象对到原因上。
| 现象 / 你看到的 | 最可能的原因 | 跳到 |
|---|---|---|
| 后台 env 列表是空的 | 平台压根没拿到这两个 env | 原因 1 |
后台有这个 env,浏览器里仍是 undefined | 前缀不对 / 缺失,或者没重新部署 | 原因 2、3 |
| preview / 分支 URL 正常,生产域名崩 | env 只勾了 Preview | 原因 4 |
组件里 process.env.SUPABASE_SERVICE_ROLE_KEY 是 undefined | 在 client 端读了 server-only key | 原因 5 |
明明设了值还报 Invalid URL / supabaseUrl is required | 值末尾带了空格 / 换行 | 原因 6 |
| 跑了几个月,这周突然 401 | 迁移途中旧 anon key 被停用 | 原因 7 |
常见原因
按命中率从高到低排。
1. 部署平台压根没拿到这两个 env
最高频。你本地有 .env,但 Vercel / Netlify / Cloudflare 后台里什么都没加。.env 不进 git(也不该进),所以部署后 process.env.X === undefined。
如何判断: 打开 Vercel / Netlify / Cloudflare Pages → Environment Variables → 这两个名字到底列没列出来?
2. 前缀和框架不匹配
只有带前缀的名字才会被写进 client bundle。前缀不对,值就从浏览器里消失了。
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 → 不强制前缀;要在 loader / window.ENV 里显式暴露
如何判断: 每一处 client 端引用都用了带前缀的名字吗?在 'use client' 组件里裸写 process.env.SUPABASE_URL 永远是 undefined。
3. 这次部署是在加 env 之前就 build 好的
最浪费时间的一种。NEXT_PUBLIC_* / VITE_* / PUBLIC_* 的值是在 build 时 写死进去的,不是 runtime 读取的。如果你加了 env 但只更新了后台,那个早就 build 好的 bundle 里写死的还是 undefined。必须触发一次全新的 build。
注意:Vercel 的 build cache 不会 挡住 env 更新——cache 缓存的是 node_modules 和框架产物,每次部署都会拿到最新的 env 值。坑在于:你部署的那个 commit 是在加 env 之前 就 build 完的。一次干净的 redeploy 两个问题一起解决。
如何判断: 你刚加完 env,后台也显示了,但线上 bundle 没变,因为根本没跑新 build。
4. 只设了 Preview,没设 Production
Vercel 的 env scope 分 Development / Preview / Production。只勾了 Preview,生产域名照样 undefined。这些值绝不会在 scope 之间自动同步。
如何判断: 分支 / preview URL 正常,生产域名不行。
5. 在 client 端读了 server-only env
SUPABASE_SERVICE_ROLE_KEY / sb_secret_... 没有公开前缀,所以在浏览器里就是 undefined——而且这是对的。secret / service-role key 拥有完整数据库权限、能绕过 Row Level Security,绝不能流到 client。
如何判断: 你在 client component 里读了一个不带前缀的 key。把这段代码挪到 server route、Server Action 或 Edge Function。
6. 值末尾带了空格或换行
复制 key 时多带了空格或 \n,初始化时 URL parse 失败、报 Invalid URL,尽管「值确实设了」。
如何判断: 在后台把值 trim 一下就好了。重新从 Supabase 仔细复制一遍。
7. 迁移途中旧 anon key 被停用(2026 新增)
截至 2026 年 6 月,Supabase 正在推行新的 key 体系,旧的 anon / service_role JWT key 计划在 2026 年底前停用。新项目下发的是 sb_publishable_...(client 安全,替代 anon)和 sb_secret_...(仅 server,替代 service_role)。如果队友在后台把 legacy key 停掉了,所有还在用旧 anon key 的请求就会开始返回 401 / Invalid API key,哪怕那个 env 还「在」。
如何判断: 跑了好几个月,某次 key 轮换后才崩,而且报的是 auth 错误而不是 undefined。见 Step 8。
最短修复路径
Step 1:到 Supabase 后台复制正确值
2026 年 Supabase 把 keys 从旧的 API settings 里拆了出来,所以菜单路径变成:
Supabase Dashboard → Project → Settings → API
→ Project URL (https://<ref>.supabase.co)
Supabase Dashboard → Project → Settings → API Keys
→ Publishable key (sb_publishable_...) ← 浏览器端用这个
→ Legacy API Keys 标签页 → anon public(eyJ...) ← 还没迁移就用这个
两种 key 可以同时生效,所以你能一个 client 一个 client 地迁。复制时别带前后空格。
Step 2:到部署平台添加 env
Vercel:
Project → Settings → Environment Variables
→ Key: NEXT_PUBLIC_SUPABASE_URL Value: https://<ref>.supabase.co
→ Key: NEXT_PUBLIC_SUPABASE_ANON_KEY Value: sb_publishable_...(或 eyJ... 旧 key)
→ Environments: 勾上 Production + Preview + Development
Netlify:
Site configuration → Environment variables → Add a variable
→ "Deploy contexts" 选 All
Cloudflare Pages:
Settings → Variables and Secrets → Production(以及 Preview)
→ build 时要读的变量必须设在这里,VITE_ / NEXT_PUBLIC_ 才会被 inline 进去
确认所有环境都勾上。在 Cloudflare 上,带 client 前缀的变量必须在 build 时就存在,光设 runtime 不会被 inline。
Step 3:grep 代码确认前缀
# Next.js——每处 client 引用都必须是 NEXT_PUBLIC_ 名字
grep -rn "process.env.*SUPABASE" src/
# Vite
grep -rn "import.meta.env.*SUPABASE" src/ # 应是 VITE_SUPABASE_*
# Astro
grep -rn "import.meta.env.*SUPABASE" src/ # 应是 PUBLIC_SUPABASE_*
不匹配的改对:
// Next.js
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
Step 4:强制重新部署
光改后台没用——必须重新 build,新值才会被 inline 进去。
Vercel:
Deployments → 最新 → "..." 菜单 → Redeploy
→ 取消勾选 "Use existing Build Cache"(强制干净重 build)
Netlify:
Deploys → Trigger deploy → "Clear cache and deploy site"
Cloudflare Pages:
Deployments → 最新 → "Retry deployment"(用当前变量重 build)
Step 5:线上验证
打开生产 URL,开 DevTools → Console。因为值是 inline 进去的,这个变量会实打实地出现在已发布的 bundle 里:
// Next.js
console.log(process.env.NEXT_PUBLIC_SUPABASE_URL);
// → "https://<ref>.supabase.co",不是 undefined
// Vite
console.log(import.meta.env.VITE_SUPABASE_URL);
也可以确认一次真实请求能打到 Supabase:
const { error } = await supabase.from('any_table').select('*').limit(1);
console.log(error ?? 'reached Supabase');
还是 undefined → 前缀不对(Step 3)或 build cache 没清(Step 4)。报 401 / Invalid API key → 是 key 本身的问题,不是 env 接线(Step 8)。
Step 6:加 boot 时校验
让下次缺 env 直接 build 失败,而不是让用户在 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,
});
build 阶段因为缺 env 直接挂,比生产页面默默渲染一片空白要省事得多。
Step 7:把 server-only key 分清
// ✅ client 可见(低权限,可以安全下发)
NEXT_PUBLIC_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY // 或 sb_publishable_...
// ❌ 只 server 用——绝不能加 NEXT_PUBLIC_ / VITE_ / PUBLIC_ 前缀
SUPABASE_SERVICE_ROLE_KEY // 或 sb_secret_... 完整 DB 权限,绕过 RLS
一旦 secret / service-role key 进了 client bundle,就当它已经泄露:立刻到 Settings → API Keys 轮换。
Step 8:如果你正处在 key 迁移途中
如果请求报的是 401 / Invalid API key 而不是 undefined,那 env 接线没问题,问题在 key。切到 publishable key,并同步更新 env 和任何写死的兜底值:
NEXT_PUBLIC_SUPABASE_ANON_KEY = sb_publishable_... # 替代 eyJ... anon
SUPABASE_SECRET_KEY = sb_secret_... # 替代 service_role
一次迁一个 client——两种 key 在你停用 legacy key 之前都一直有效,所以不存在「一刀切」的切换日。等到确认没有任何东西依赖 legacy key 了,再把它停掉。
预防建议
- 在 README 或
CLAUDE.md里列出所有必须的 env(连前缀一起)。 - 项目里放一个进了 git 的
.env.example(不含真值)跟.env同步,新人照着配。 - 用 zod 在 boot 时校验 env,缺什么立刻 fail-fast。
- 任何含
_KEY、_SECRET、_TOKEN或sb_secret_的名字,永远不加NEXT_PUBLIC_/VITE_/PUBLIC_前缀。 - 加新 env 时,一次性把 Local + Preview + Production 三处都设好。
- 改完 env 习惯性「Clear cache and redeploy」,别假设增量 build 会带上新值。
- 把 env 改动写进 PR template 或 deploy checklist。
- 跟进 Supabase 的 key 迁移:计划在 2026 年底停用期之前从 legacy
anon/service_role切走。
常见问题
为什么本地行、生产不行?
本地进程直接读 .env;云端 build 不读。每个平台都要在它自己的后台里配好这些值,然后值才会在 build 时被 inline 进 bundle。后台没这一条,就没这个值。
我在 Vercel 上加了,怎么还是 undefined?
因为 NEXT_PUBLIC_* 是在 build 时编进 JS 的,你加 env 之前就 build 好的那个部署里写死的仍是 undefined。重新部署(Step 4),让全新的 build 把新值 inline 进去。
该用 anon key 还是新的 publishable key?
今天两种都能用。截至 2026 年 6 月,旧的 anon / service_role JWT key 计划在 2026 年底前停用,所以新项目优先用 sb_publishable_...(client)和 sb_secret_...(server)。两种 key 同时有效,可以平滑迁移。
把 anon / publishable key 暴露在浏览器里安全吗?
安全。它是低权限、本就设计来下发到 client 代码里的;真正保护数据的是你的 Row Level Security 策略。危险的是 service-role / sb_secret_... key,那个必须留在 server 端。
key 现在在哪找?旧的 API 页面变了。
Project URL 仍在 Settings → API 下。keys 在 2026 年挪到了独立的 Settings → API Keys 页,旧的 anon / service_role 在 Legacy API Keys 标签页里。
跑了好几个月,突然 401。 这不是 env 接线问题——是 key 的问题。多半是迁移期间某个 legacy key 被轮换或停用了。把 env 切到 publishable / secret key(Step 8)。