使用 Next.js 与 Supabase 构建全栈认证应用:with-supabase 官方 Starter 深度指南
导读
with-supabase 是 Next.js 官方仓库(本仓库 examples/with-supabase)中内置的 Supabase Starter Kit,它演示了如何通过 @supabase/ssr 以 Cookie 方式配置 Supabase Auth,从而让用户会话在整个 Next.js 技术栈中可用——包括 Client Components、Server Components、Route Handlers、Server Actions 与 Proxy(Middleware 的继任者)。读完本文,你将掌握:如何用一行命令创建该模板、如何获取并配置 Supabase 环境变量、客户端/服务端/代理三层 Supabase 客户端为何必须分开创建、Email 密码认证与邮箱确认的完整闭环,以及如何将示例部署到 Vercel 并替换为自定义 UI。
什么是 with-supabase Starter Kit
它是官方在 create-next-app 中内置的一个示例模板,核心价值是“开箱即用的全栈认证骨架”:
- 覆盖 Next.js 全栈使用场景:App Router、Pages Router、Proxy、Client、Server 都能直接工作;
- 基于
@supabase/ssr包,将 Supabase Auth 与会话统一收敛到浏览器 Cookie 中; - 内置基于密码的认证模块(登录、注册、忘记密码、更新密码),界面由 Supabase UI Library 风格驱动;
- 使用 Tailwind CSS 负责样式、shadcn/ui 提供可复用的基础组件;
- 支持通过 Supabase Vercel Integration 一键部署,环境变量自动注入 Vercel 项目。
在仓库中的模板代码位于 examples/with-supabase,其 UI 骨架由 examples/with-supabase/components/ui 下的 shadcn 组件(button、card、input、label、badge、checkbox、dropdown-menu)以及 examples/with-supabase/app/globals.css 构成。
快速开始:创建并运行模板
1. 准备一个 Supabase 项目
首先要有一个 Supabase 项目。你可以通过 Supabase 仪表盘新建项目,并在项目 API 设置中找到后续要填写的 URL 与密钥。
2. 通过 create-next-app 创建应用
模板支持 npm、yarn、pnpm 三种包管理器,任选其一:
npx create-next-app --example with-supabase with-supabase-app
yarn create next-app --example with-supabase with-supabase-app
pnpm create next-app --example with-supabase with-supabase-app
该命令会把本仓库 examples/with-supabase 目录整体复制为本地项目 with-supabase-app。进入目录:
cd with-supabase-app
3. 配置环境变量
把模板根目录的 .env.example 重命名为 .env.local,并填入两个核心变量:
NEXT_PUBLIC_SUPABASE_URL=[INSERT SUPABASE PROJECT URL]
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=[INSERT SUPABASE PROJECT API PUBLISHABLE OR ANON KEY]
模板中的示例文件(examples/with-supabase/.env.example)给出了可对照的占位格式。关于变量名,有两点需要注意:
- 本模板使用
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,对应 Supabase 新推出的 publishable(可发布)密钥格式; - 在过渡期内,旧版 anon 密钥与新版 publishable 密钥都可以放在这个变量名下使用。如果你的 Supabase 仪表盘显示的是
NEXT_PUBLIC_SUPABASE_ANON_KEY,将其值填到本模板变量中即可。
两个值都可以在 Supabase 项目 API 设置(Connect 面板)中找到。
说明:
NEXT_PUBLIC_前缀的变量会被打包进浏览器端代码,因此只能存放可在客户端公开的安全凭据(URL、anon/publishable 密钥),绝不能存放 service_role 之类的服务端密钥。
4. 启动本地开发服务
npm run dev
模板 package.json(examples/with-supabase/package.json)中预置了 dev、build、start、lint 四个脚本,开发服务器启动后访问 http://localhost:3000/ 即可看到首页。
如果你还希望连数据库都本地化,可以按 Supabase 官方本地开发文档同时在本机运行 Supabase。
一键部署到 Vercel
模板顶部 README 区域提供了 Vercel 一键部署入口(Deploy 按钮)。Vercel 部署流程会引导你完成两个动作:
- 创建 Supabase 账户与项目(若尚未创建);
- 将模板仓库克隆到你的 GitHub 名下,并以此为源码部署。
安装 Supabase Integration 之后,所有相关环境变量都会被自动赋值到 Vercel 项目,部署即可直接工作。如果你不想走“先部署再本地开发”的路径,也可以直接遵循上一节的本地运行步骤,二者互不影响。README 中说明了该一键部署方式同样会把 Starter 克隆到你的 GitHub,之后可 clone 到本地继续开发。
三层架构:客户端、服务端与代理如何协同
with-supabase 与旧版模板最大的不同,是把所有 Supabase 客户端创建逻辑收敛到 examples/with-supabase/lib/supabase 目录下的三个文件,对应浏览器、服务端、代理三种运行环境。这种“一处封装、处处复用”的结构正是 @supabase/ssr 推荐的分层用法。
客户端(Client):createBrowserClient
examples/with-supabase/lib/supabase/client.ts 导出一个供浏览器端(Client Component)调用的 createClient():
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
}
它由 @supabase/ssr 的 createBrowserClient 创建。在客户端,Cookie 由浏览器自动携带,因此无需手动配置 cookie 读写逻辑。凡是标记了 "use client" 的组件——例如登录表单 examples/with-supabase/components/login-form.tsx 与注册表单 examples/with-supabase/components/sign-up-form.tsx——都直接引用这个 createClient 来调用 supabase.auth.signInWithPassword(...)、supabase.auth.signUp(...) 等方法。
服务端(Server):createServerClient + cookies
examples/with-supabase/lib/supabase/server.ts 面向 Server Components、Route Handlers、Server Actions,它从 next/headers 的 cookies() 读取当前请求的 Cookie 存储:
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have proxy refreshing user sessions.
}
},
},
},
);
}
这里有两处值得深读的实现细节:
- 代码注释特别强调:如果使用 Fluid compute(流式计算部署),不要把该客户端放到全局变量里,应该在每次调用时创建新实例。因为服务端客户端绑定了请求级 Cookie 上下文,全局复用会导致会话串号或过期。
setAll中捕获异常是刻意的:当cookies()的写入方法在 Server Component 渲染期间被调用时会抛错,此时应静默忽略——会话刷新交给 Proxy 完成(见下文),否则会出现 “Server Component 无法 set cookie” 的报错。
代理(Proxy):刷新会话与路由守卫
模板在根目录使用 proxy.ts(examples/with-supabase/proxy.ts)而不是传统 middleware.ts,把每个请求交给 examples/with-supabase/lib/supabase/proxy.ts 的 updateSession(request) 处理:
import { updateSession } from "@/lib/supabase/proxy";
import { type NextRequest } from "next/server";
export async function proxy(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
config.matcher 排除了静态资源(_next/static)、图片优化产物(_next/image)、favicon 以及各种图片扩展名,其余请求都会进入代理。若想调整范围,可直接修改这条正则。
再看 updateSession 的核心逻辑(examples/with-supabase/lib/supabase/proxy.ts):
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
// 未配置环境变量时直接跳过校验(可在完成配置后删除此分支)
if (!hasEnvVars) {
return supabaseResponse;
}
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options),
);
},
},
},
);
const { data } = await supabase.auth.getClaims();
const user = data?.claims;
if (
request.nextUrl.pathname !== "/" &&
!user &&
!request.nextUrl.pathname.startsWith("/login") &&
!request.nextUrl.pathname.startsWith("/auth")
) {
// 未登录:重定向到登录页
const url = request.nextUrl.clone();
url.pathname = "/auth/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
这段代码集中体现了三个易错点,模板都用注释标明了:
- 不要重复使用
createServerClient的实例:注释明确要求“不要在createServerClient与supabase.auth.getClaims()之间运行任何业务代码”,否则可能造成用户被随机登出的难以排查的问题。 setAll必须双向同步 Cookie:把 Cookie 同时写回request与新的supabaseResponse,避免浏览器与服务器 Cookie 失步而提前终止会话。- 必须原样返回
supabaseResponse:如果要用NextResponse.next()构造新响应,必须传入request、拷贝 Cookie、不要改动 Cookie,再返回新响应对象。
代理中的 hasEnvVars 来自 examples/with-supabase/lib/utils.ts,它同时封装了 shadcn 常用的 cn() 类合并工具(基于 clsx 与 tailwind-merge)。
完整的认证闭环:注册、邮箱确认、登录与退出
登录(Sign in)
登录页路由 examples/with-supabase/app/auth/login/page.tsx 渲染客户端组件 examples/with-supabase/components/login-form.tsx。提交表单时调用 supabase.auth.signInWithPassword({ email, password }),成功后 router.push("/protected") 跳转到受保护页;表单还通过 Link 指向忘记密码页 /auth/forgot-password 与注册页 /auth/sign-up。
注册与邮箱确认(Sign up + Email confirmation)
注册表单 examples/with-supabase/components/sign-up-form.tsx 会在提交前先校验两次密码输入是否一致,然后执行:
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/protected`,
},
});
if (error) throw error;
router.push("/auth/sign-up-success");
注册成功后跳转到 sign-up-success 提示页。由于设置了 emailRedirectTo,用户点击邮箱确认链接后会被带回受保护页 /protected——但此时会话尚未建立,需要先经确认路由换发会话。
邮箱确认链路在 examples/with-supabase/app/auth/confirm/route.ts:
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const next = searchParams.get("next") ?? "/";
if (token_hash && type) {
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({ type, token_hash });
if (!error) {
redirect(next); // 成功:跳转到 next 或根路径
} else {
redirect(`/auth/error?error=${error?.message}`);
}
}
redirect(`/auth/error?error=No token hash or type`);
}
Supabase 发出的确认邮件链接会携带 token_hash 与 type 查询参数,该 Route Handler 接收后用 verifyOtp 校验并把未登录用户的 token 兑换为有效会话(Cookie 由服务端客户端的 setAll 写入),最后按 next 参数(默认 /)重定向。失败则进入 examples/with-supabase/app/auth/error/page.tsx 展示错误。
提示:
auth.confirm路由还演示了 Route Handler 中服务端客户端的典型用法——在服务端校验 OTP、写入会话 Cookie 后使用redirect完成跳转。
受保护页面与退出
受保护页 examples/with-supabase/app/protected/page.tsx 是一个 Server Component:它通过 supabase.auth.getClaims() 读取用户声明,若不存在则 redirect("/auth/login"),同时用 JSON.stringify 展示 claims 供调试。
顶栏的 examples/with-supabase/components/auth-button.tsx 同样是服务端组件,根据登录态渲染“用户名 + Logout”或“Sign in / Sign up”两种状态;登出动作由客户端组件 examples/with-supabase/components/logout-button.tsx 调用 supabase.auth.signOut() 完成。注意代码中注释:getClaims() 比 getUser() 更快,适用于需要高频读取的场景。在需要完整用户信息(而非仅 claims)时再考虑 getUser()。
忘记密码与更新密码
配套的 examples/with-supabase/app/auth/forgot-password/page.tsx 与 examples/with-supabase/app/auth/update-password/page.tsx 分别由 examples/with-supabase/components/forgot-password-form.tsx 与 examples/with-supabase/components/update-password-form.tsx 实现,构成完整的密码找回→重设闭环。
双保险的鉴权策略
这套模板在“谁能访问受保护资源”上布置了两层防线,值得作为实战范式学习:
- 第一层(代理层鉴权):
updateSession在请求进入页面渲染前拦截。路径既不是/,也不以/login、/auth开头,且用户无有效 claims 时,直接 302 重定向到/auth/login。这保证未登录用户根本拿不到受保护页的 HTML。 - 第二层(服务端校验):即使请求绕过代理(例如被缓存),examples/with-supabase/app/protected/page.tsx 自身也会再次调用
getClaims()校验,无会话则redirect。同时 examples/with-supabase/app/protected/layout.tsx 为受保护区域提供了统一的导航、环境变量警告(EnvVarWarning)、认证按钮与页脚布局。
分层鉴权的好处是任一环节失效时仍有兜底,符合“服务端渲染数据必须服务端鉴权”的 Next.js 安全实践。
样式与 UI 定制
模板默认完成 shadcn/ui 风格初始化:主题色、组件变量定义在 examples/with-supabase/app/globals.css,组件配置在 examples/with-supabase/components.json,Tailwind 配置位于 examples/with-supabase/tailwind.config.ts 与 examples/with-supabase/postcss.config.mjs。
如果你不想要默认风格,README 给出了官方建议:删除 components.json,然后按 shadcn/ui 官方文档为 Next.js 重新初始化你偏好的风格。
此外,examples/with-supabase/components/tutorial 目录下的 connect-supabase-steps.tsx、fetch-data-steps.tsx、sign-up-user-steps.tsx 等组件会把首页(examples/with-supabase/app/page.tsx)变成一份交互式教学向导,引导你完成连接 Supabase、注册用户、读取数据三步走;code-block.tsx 负责高亮展示示例代码片段。
结语与延伸学习
with-supabase Starter 的价值不在于它替你写完了业务,而在于它把 “Cookie 会话在 Next.js 全栈环境下的正确姿势”固化成了一套可复用的工程结构:客户端 createBrowserClient、服务端 createServerClient + cookies()、代理层 updateSession 三段式封装,配合 matcher 静态资源排除与 getClaims 双保险鉴权,即可平滑覆盖登录注册、邮箱确认、路由守卫、登出全链路。
若想继续深入,可关注官方给出的其他方向:基于该模式的订阅计费 Starter、Cookie 认证 + App Router 的免费课程,以及 Supabase Auth 在 Next.js App Router 下的更多官方示例。结合本仓库的 examples/with-supabase 目录逐步阅读每个文件,你会对其中的设计取舍有更直观的理解。
最后提醒:无论是本地开发还是 Vercel 部署,NEXT_PUBLIC_SUPABASE_URL 与 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY 都必须在 .env.local(本地)或 Vercel 项目环境变量(线上)中正确配置;在完成配置前,代理会因 hasEnvVars 为假而跳过鉴权,页面上的 EnvVarWarning 组件则会提示你补全变量——这正是新手最容易踩的坑。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00