
字节笔记本
2026年10月7日 · 约 9 分钟读完
调自己的接口不用写主机名
浏览器里的页面要读自己的接口时,地址写相对路径就够了。fetch("/api/posts") 和页面同一个源,不需要再拼一个 base URL。要分清的是另一件事:谁在服务器上创建 Supabase 客户端,会话放在哪里。放进 localStorage 的那套在服务端渲染里读不到。

相对路径是同源,不是漏了配置
Route Handler 放在 app/api 下,客户端用 fetch("/api/...") 调用。开发时页面在 3000 端口,请求也会打到 3000,由 Next 自己进到对应的路由文件。写成 http://localhost:3000/api/... 在本机能跑,换域名就断。需要绝对地址的只有服务器去调外部服务,或者另有一个独立的 API 域名。
路由文件导出 GET、POST 这些函数,参数是请求。返回 Response 或 NextResponse。这和页面组件不是同一条调用。客户端不要去 import 路由文件里的函数,那是服务器代码。
auth-helpers 那套已经标成旧的
早期服务端组件的例子是 @supabase/auth-helpers-nextjs 的 createServerComponentClient({ cookies })。对话里对照官方说明时,这包被标成过时。现在要装的是两个包:@supabase/supabase-js 和 @supabase/ssr。前者是客户端本身,后者是让它能在不同框架里读写 Cookie 的适配。只装 js、不装 ssr,服务器上仍然不知道会话在 Cookie 里。
@supabase/ssr 当时还标着接口可能变,但方向已经明确:会话放 Cookie,才能在服务器组件、Route Handler 和中间件里读到同一个人。localStorage 只存在于浏览器。服务器渲染第一遍 HTML 时,没有 localStorage。
Cookie 的读写要交给调用方
createServerClient 不假设你用的是 Next 的 cookies()。它接收 get 和 set,或后来的 getAll 和 setAll。在服务器组件里,get 从 cookieStore.get(name)?.value 读。set 调用 cookieStore.set。Route Handler 里 set 要写在你返回的 NextResponse 上,不然令牌刷新了,浏览器却没收到 Set-Cookie。
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
cookieStore.set({ name, value, ...options })
},
},
}
)公开的 URL 和匿名键可以进环境变量的 NEXT_PUBLIC_ 前缀。服务角色密钥不要用这个前缀,也不要放进这个客户端。匿名键配合用户自己的 Cookie,权限才是那个用户的。用服务角色等于绕过所有行级权限。
中间件里也要有一份同样的客户端,在每次导航时把快过期的令牌换新,并把新 Cookie 贴到响应上。只有 Route Handler 里能读 Cookie、中间件不刷新,用户会在令牌过期后突然变成未登录,而且只发生在服务器组件,浏览器里的旧会话看起来还在。
三处客户端,一种会话
浏览器里一份客户端,读写本机存储或 Cookie。服务器组件和 Route Handler 里一份,通过 cookies() 读写。中间件里一份,在请求边上刷新。三处都用 @supabase/ssr 的约定,不要混用已经过时的 auth-helpers。页面调自己的接口用相对路径。会话能不能到服务器,取决于 Cookie 适配器有没有把 get 和 set 接上,不取决于 fetch 里有没有写主机名。

服务器组件和路由里的 set 并不相同
服务器组件渲染期间,Next 不允许随手写 Cookie 的某些时机。set 如果在纯渲染里抛错,说明刷新不该发生在组件里,而该发生在中间件。Route Handler 是一次请求的终点,可以在返回的响应上 set。两边复制同一段 cookies 适配器,不代表两边的失败方式一样。组件里的 set 失败时,先确认中间件是否已经在刷新令牌。
get 返回的是字符串或空。空不一定是未登录,也可能是名字写错。Supabase 的会话 Cookie 名是库自己定的,不要在 get 里写死一个你猜的名字再到处判断。把 get 交给 cookieStore.get(name),name 由库传入。
浏览器上的客户端用另一套。它可以用本地存储,但一旦服务器也要知道这个用户,登录完成时必须有 Cookie。只在浏览器里 login、服务器组件里 createServerClient 读不到会话,页面会渲染成客人,接口却在浏览器里带着本地会话去调用,出现半登录。统一到 Cookie 之后,相对路径的 fetch 会自动带上同站 Cookie,不必再手工塞 Authorization,除非你的 Route Handler 明确要求头。
两个包的分工可以用安装命令记住:supabase-js 是客户端,ssr 是 Cookie 适配。对话里特别比较过只看名字会装错的情况。auth-helpers-nextjs 的导入如果还在旧页面里,和新的 createServerClient 同时存在,一个读 Cookie,一个读旧的存储键,会话会随机掉。迁移时全库搜旧包名,而不是只改新建的那个文件。
环境变量缺失时,createServerClient 会在运行时炸,而不是在类型上炸。非空断言只满足编译器。部署清单里同时检查 URL 和匿名键。不要把服务角色键放进 NEXT_PUBLIC。前缀的含义是会进浏览器包。Route Handler 即使跑在服务器上,只要引用了这个前缀的变量,值就可能被打进客户端包。
流程图如果要画,三条线就够:浏览器 fetch 相对路径,服务器用 Cookie 适配器创建客户端,中间件在下一次导航前刷新。不要把 base URL 画成必填。它只在离开这个源时才出现。 客户端组件上的按钮如果要带登录态调用 Route Handler,用相对路径,并默认让 fetch 带上 Cookie。凭据模式在同源时本来就会带。改成 omit 之后,接口永远看到客人,你会去查 Supabase 键,其实是 fetch 把 Cookie 丢了。
服务器上创建客户端的函数可以按调用场景拆成三个小函数:中间件、服务器组件、路由。不要合成一个忽略返回值差异的万能函数。路由必须把 set 作用到最终响应。组件只读的话,set 可以留空并接受刷新发生在别处。空的 set 如果被库调用,说明该次刷新没有地方可写,用户会在这次渲染之后才在下一次导航拿到新 Cookie。能接受这个延迟,就要在中间件保证下一次一定写上。
旧的 auth-helpers 示例还会从页面里直接 select。换成 ssr 之后,查询代码可以不动,变的是创建客户端的那几行。审查差异时盯住导入和 cookies 适配器,不要把查询重写一遍。 匿名键和 URL 缺失时给出明确的启动错误,比等到第一次请求才在栈里看到空指针友好。可以在创建函数入口检查这两个变量。检查失败就抛出一个说明缺哪一个名字的错误。不要把键的值打进错误文本。
Route Handler 返回 JSON 时,记得把刷新过的 Cookie 留在同一个响应对象上。先创建响应、再 set、再返回。如果 set 发生在另一个临时响应上,调用方看到的仍是没有 Set-Cookie 的 JSON。登录后立刻刷新页面又掉线,先查这一处。 相对路径、Cookie 适配器、过时的 auth-helpers 不要并存。这是这次迁移的全部范围。查询语句可以保持原样。先让同一个人在浏览器、路由和服务器组件里是同一个人,再谈接口返回什么字段。
同源的 fetch 会带上 Cookie。跨源才需要额外配置凭据和允许的源。页面和 Route Handler 部署在一起时,不要为了保险改成跨源写法,那会把简单的 Cookie 会话做成需要额外头的接口。



