首页
/ 使用 Next.js 与 Supabase 构建全栈认证应用:with-supabase 官方 Starter 深度指南

使用 Next.js 与 Supabase 构建全栈认证应用:with-supabase 官方 Starter 深度指南

2026-09-07 13:55:09作者:齐添朝

导读

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)中预置了 devbuildstartlint 四个脚本,开发服务器启动后访问 http://localhost:3000/ 即可看到首页。

如果你还希望连数据库都本地化,可以按 Supabase 官方本地开发文档同时在本机运行 Supabase。

一键部署到 Vercel

模板顶部 README 区域提供了 Vercel 一键部署入口(Deploy 按钮)。Vercel 部署流程会引导你完成两个动作:

  1. 创建 Supabase 账户与项目(若尚未创建);
  2. 将模板仓库克隆到你的 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/ssrcreateBrowserClient 创建。在客户端,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/headerscookies() 读取当前请求的 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.tsexamples/with-supabase/proxy.ts)而不是传统 middleware.ts,把每个请求交给 examples/with-supabase/lib/supabase/proxy.tsupdateSession(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;
}

这段代码集中体现了三个易错点,模板都用注释标明了:

  1. 不要重复使用 createServerClient 的实例:注释明确要求“不要在 createServerClientsupabase.auth.getClaims() 之间运行任何业务代码”,否则可能造成用户被随机登出的难以排查的问题。
  2. setAll 必须双向同步 Cookie:把 Cookie 同时写回 request 与新的 supabaseResponse,避免浏览器与服务器 Cookie 失步而提前终止会话。
  3. 必须原样返回 supabaseResponse:如果要用 NextResponse.next() 构造新响应,必须传入 request、拷贝 Cookie、不要改动 Cookie,再返回新响应对象。

代理中的 hasEnvVars 来自 examples/with-supabase/lib/utils.ts,它同时封装了 shadcn 常用的 cn() 类合并工具(基于 clsxtailwind-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_hashtype 查询参数,该 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.tsxexamples/with-supabase/app/auth/update-password/page.tsx 分别由 examples/with-supabase/components/forgot-password-form.tsxexamples/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.tsexamples/with-supabase/postcss.config.mjs

如果你不想要默认风格,README 给出了官方建议:删除 components.json,然后按 shadcn/ui 官方文档为 Next.js 重新初始化你偏好的风格

此外,examples/with-supabase/components/tutorial 目录下的 connect-supabase-steps.tsxfetch-data-steps.tsxsign-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_URLNEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY 都必须在 .env.local(本地)或 Vercel 项目环境变量(线上)中正确配置;在完成配置前,代理会因 hasEnvVars 为假而跳过鉴权,页面上的 EnvVarWarning 组件则会提示你补全变量——这正是新手最容易踩的坑。

登录后查看全文
热门项目推荐
相关项目推荐