Supabase 实战:用 Next.js + Postgres RLS 从零构建多用户 Todo 应用
本教程基于开源仓库中的官方示例 nextjs-todo-list(位于 examples/todo-list/nextjs-todo-list),完整拆解一个「认证 + 数据隔离」的生产级待办应用:前端使用 Next.js + Tailwind 构建,后端由 Supabase 托管的 Postgres 数据库提供 REST 化 API 与实时能力。读完本教程,你将掌握 Supabase 项目的三种启动方式(Vercel 一键部署、CLI 对接远程项目、本地开发),并理解 Postgres Row Level Security(RLS)如何用最少的代码实现"每个用户只能读写自己的数据"。
示例架构总览
该示例的技术选型非常典型,它直接展示了 Supabase 的核心理念——把 Postgres 当作后端来用:
- 前端:Next.js(React 生产级框架)+ Tailwind(样式与布局)+ Supabase 客户端库(用户管理与实时数据同步)。
- 后端:Supabase 托管平台提供的 Postgres 数据库,通过 Auto-generated RESTful API 暴露给客户端使用,无需自行编写后端接口。
从源码目录结构可以清晰看到这套"无自建后端"的分层设计(见 examples/todo-list/nextjs-todo-list):
nextjs-todo-list/
├── components/
│ └── TodoList.tsx # Todo 列表:查询/新增/删除/勾选完成
├── lib/
│ ├── initSupabase.ts # 初始化 Supabase 浏览器客户端
│ └── schema.ts # 数据库表对应的 TypeScript 类型
├── pages/
│ ├── index.tsx # 首页:登录/注册 + 登出 + 会话路由
│ └── _app.tsx # Next.js 应用入口
├── styles/
│ ├── tailwind.css # Tailwind 源文件(@tailwind 指令 + 自定义类)
│ └── app.css # Tailwind 编译产物(由构建脚本生成)
└── supabase/
├── migrations/ # 数据库迁移(建表 + RLS 策略)
└── config.toml # 本地开发环境配置
注意到 styles/app.css 是编译产物,它由 package.json 中的脚本驱动生成。开发时,Tailwind 通过 concurrently "npm run dev:css" "next dev" 以 watch 模式实时编译;样式源文件在 styles/tailwind.css,其中用 @apply 封装了 btn-black 等自定义组件类。
方式一:通过 Vercel 一键部署并新建项目
官方为快速体验准备了 Vercel 部署入口。部署向导会引导你完成 创建 Supabase 账号与项目;安装 Supabase 集成后,所有相关环境变量会被自动写入 Vercel,部署完成后应用立即可用。整体流程如下:
第 1 步:新建 Supabase 项目
前往 Supabase Dashboard 注册并创建一个新项目,等待数据库完成启动。创建项目时请留意项目所在地域与数据库密码,后续 SQL 与 API 都依赖该项目的实例地址。
第 2 步:运行 "Todo List" Quickstart
数据库启动后,进入项目控制台的 SQL Editor 页签,向下滚动找到 TODO LIST: Build a basic todo list with Row Level Security,点击运行。这段 SQL 会创建 todos 表并写入基于 auth.uid() 的 RLS 策略。该 Quickstart 对应的迁移文件即仓库内的 supabase/migrations/20230712094349_init.sql,我们会在文末 RLS 章节逐行讲解。
第 3 步:获取项目 URL 与密钥
点击项目设置(齿轮图标),进入 API 页签,找到:
Project URL(即 API URL)anonpublic key(客户端密钥,新版客户端中对应publishablekey 语义)
说明:
anonkey 是客户端密钥,它只允许对数据库进行"匿名访问";当用户登录后,请求会自动切换为携带用户自身登录令牌(JWT)。这正是 RLS 能够生效的前提——服务端依据 JWT 中的用户身份裁决每一行数据的可见性。下文 RLS 章节会详细解释。
重要安全警告:
secret(service_role)密钥拥有完整的数据访问权限,会绕过所有安全策略。这类密钥必须严格保密,只能用于服务端环境,绝不能出现在客户端或浏览器代码中。凡是被浏览器直接访问的代码,一律使用anon/publishable密钥。
本地与远程项目的环境变量配置
将上述 URL 与密钥填入环境变量即可。仓库提供了两份模板(注意示例项目当前使用的变量名为 PUBLISHABLE_KEY):
.env.example(本地开发):
# Update these with your Supabase details from your project settings > API
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
NEXT_SITE_URL=http://localhost:3000
NEXT_REDIRECT_URLS=http://localhost:3000/
.env.production.example(生产环境):
# Get these from your API settings: https://supabase.com/dashboard/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key
# Get this from your Vercel project settings
NEXT_SITE_URL=https://<your-vercel-project>.<your-vercel-org-name>.vercel.app/
NEXT_REDIRECT_URLS=https://<your-vercel-project>*.vercel.app/,https://<your-vercel-project>*.vercel.app/**
各变量职责如下表:
| 环境变量 | 用途 | 取值示例 |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase API/数据库实例地址 | 本地 http://127.0.0.1:54321;生产为 https://<ref>.supabase.co |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY |
浏览器客户端使用的 anon/publishable JWT | 从项目 Settings → API 获取 |
NEXT_SITE_URL |
Auth 配置中的站点地址,用于邮件内链接与重定向白名单校验 | 本地 http://localhost:3000;生产为 Vercel 域名 |
NEXT_REDIRECT_URLS |
认证后允许跳转的 URL 白名单(支持 * 通配) |
本地 http://localhost:3000/;生产为 https://<proj>*.vercel.app/** |
前两者被 lib/initSupabase.ts 直接消费:
import { createBrowserClient } from '@supabase/ssr'
export const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
代码使用 @supabase/ssr 提供的 createBrowserClient 创建浏览器端单例(该方式兼容 Next.js 的 Server Components / SSR 体系,帮助安全地管理会话 Cookie)。后两者则被本地 Auth 配置消费,见下文 config.toml。
NEXT_PUBLIC_ 前缀意味着这些值会内联进浏览器打包产物——再次印证:写入此处的只能是 anon 公钥,绝不能是 service_role 密钥。
方式二:CLI 对接远端 Supabase 项目并同步配置与迁移
除 Vercel 集成外,也可以使用 Supabase CLI 把本地仓库与远程项目对接,实现"配置即代码 + 迁移即代码"。
- 在 Supabase Dashboard 创建或选择一个项目。
- 复制并填充 dotenv 模板:
cp .env.production.example .env.production
- 链接本地项目,并把本地配置与远端合并:
SUPABASE_ENV=production npx supabase@latest link --project-ref <your-project-ref>
SUPABASE_ENV=production 会指示 CLI 读取 .env.production 中的变量。项目引用号(project ref)可在项目 URL https://<project-ref>.supabase.co 中找到。
- 同步项目配置(Auth 设置、API 设置等,来自
supabase/config.toml):
SUPABASE_ENV=production npx supabase@latest config push
- 同步数据库结构(把
supabase/migrations下的迁移应用到远端数据库):
SUPABASE_ENV=production npx supabase@latest db push
本地开发时,supabase/config.toml 是整套配置的权威来源,config push / db push 推送的正是它及其对应的迁移目录。该文件值得逐项研读,几个关键段如下:
# A string used to distinguish different Supabase projects on the same host.
project_id = "nextjs-todo-list"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API...
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returned from a view, table, or function.
max_rows = 1000
[db]
# Port to use for the local database URL.
port = 54322
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's.
major_version = 15
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = false
[auth]
enabled = true
# The base URL of your website...
site_url = "env(NEXT_SITE_URL)"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["env(NEXT_REDIRECT_URLS)"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), max 604,800 (1 week).
jwt_expiry = 3600
enable_refresh_token_rotation = true
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
enable_anonymous_sign_ins = false
enable_manual_linking = false
[auth.email]
enable_signup = true
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = true
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another
# signup confirmation or password reset email.
max_frequency = "1m0s"
otp_length = 6
otp_expiry = 3600
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
几点与示例强相关的细节:
site_url与additional_redirect_urls通过env(...)语法引用外层环境变量NEXT_SITE_URL/NEXT_REDIRECT_URLS,这解释了为何 env 模板中必须包含这两项——它们决定了认证邮件里的链接与登录后允许跳转的地址白名单。max_frequency = "1m0s"控制两次发送确认/重置邮件的间隔(见下文 Vercel Preview 分支的例子,演示如何改它来测试预览环境行为)。- 本地运行时
.env.example中的 URL 指向http://127.0.0.1:54321、publishable key 使用supabase-demo角色的演示 JWT,与本地supabase start启动的 API(端口 54321)、数据库(端口 54322)及 inbucket 邮件测试服务(端口 54324)一一对应。
方式三:Vercel Preview 与数据库分支(Branching)
Supabase 与 Vercel 预览分支深度集成,可为每个分支分配一个专属的 Supabase 项目。这样,数据库迁移或服务配置可以先在独立环境中验证,确认无误后再应用到生产,避免"改一处配置影响线上"。
操作步骤:
- 确保 Vercel 项目已关联 Git 仓库。
- 在 Vercel 中为 Preview 环境配置以下环境变量:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
- 新建一个 Git 分支并做出修改(例如修改上文中 config.toml 里的
max_frequency),推送分支。- 打开 Pull Request 触发 Vercel + Supabase 集成。
- 部署成功后,预览环境即会反映这些变更——Auth 邮件频率限制、迁移脚本等改动都可以先在此分支环境上安全验证。
典型工作流是:PR 合入前,Supabase 为预览分支克隆一份 schema;合入主分支后,再通过 CI 或人工执行 db push 把已验证的迁移应用到生产。
登录/注册与会话管理:从源码看认证闭环
回到客户端代码。入口页 pages/index.tsx 实现了一整套认证 UI 与状态切换逻辑:
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => setSession(session))
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => setSession(session))
return () => subscription.unsubscribe()
}, [])
关键设计:
- 页面加载时先通过
supabase.auth.getSession()恢复已有会话;随后用supabase.auth.onAuthStateChange(...)订阅认证状态变化,使登录/登出后 UI 自动重渲染,并在组件卸载时subscription.unsubscribe()清理监听。 - 没有 session 时渲染登录表单,支持
sign-in/sign-up两种模式切换:
if (mode === 'sign-in') {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) setError(error.message)
} else {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: { emailRedirectTo: `${window.location.origin}/` },
})
if (error) {
setError(error.message)
} else if (data.user && !data.session) {
setMessage('Check your email for a confirmation link to complete sign up.')
}
}
- 密码框设置了
minLength={6};注册分支在data.user已创建但data.session为空时,提示用户去邮箱完成确认(对应 supabase/config.toml 中enable_confirmations = true的默认配置)。注册成功后通过emailRedirectTo把确认链接带回应用首页。 - 登录成功后进入主界面,
Logout按钮调用supabase.auth.signOut()结束会话。
值得一提的是 UI 整体是无状态后端的:没有自建的 session 中间件、没有 ORM,登录态由 Supabase Auth 签发并刷新 JWT 来维持,应用只依赖 Session 对象判断"当前是谁"。
Todo 列表的前端 CRUD:一行 API 搞定增删改查
登录后,components/TodoList.tsx 承载全部业务逻辑。组件从 session.user 拿到当前用户 id,其状态类型直接来自数据库 schema:
import { Database } from '@/lib/schema'
import { supabase } from '@/lib/initSupabase'
import { Session } from '@supabase/supabase-js'
type Todos = Database['public']['Tables']['todos']['Row']
schema.ts 手工维护的 Database 接口完整描述了 todos 表的 Row / Insert / Update 三类结构(id、inserted_at、is_complete、task、user_id),让前端查询结果具备全程类型安全。
查询(挂载时拉取,按 id 升序):
const { data: todos, error } = await supabase
.from('todos')
.select('*')
.order('id', { ascending: true })
if (error) console.log('error', error)
else setTodos(todos)
注意这里没有任何 where user_id = ... 条件——数据过滤完全交给服务端 RLS 完成。这正是这套架构的精髓:客户端无法越权,因为 SQL 层替每个用户做了过滤。
新增(trim 后写入,空任务直接忽略,并带上 user.id):
const { data: todo, error } = await supabase
.from('todos')
.insert({ task, user_id: user.id })
.select()
.single()
if (error) setErrorText(error.message)
else {
setTodos([...todos, todo])
setNewTaskText('')
}
删除(使用 .throwOnError() 把错误转为异常,便于 try/catch 统一处理):
try {
await supabase.from('todos').delete().eq('id', id).throwOnError()
setTodos(todos.filter((x) => x.id != id))
} catch (error) {
console.log('error', error)
}
勾选完成(乐观更新 + 回写数据库):
const toggle = async () => {
try {
const { data } = await supabase
.from('todos')
.update({ is_complete: !isCompleted })
.eq('id', todo.id)
.throwOnError()
.select()
.single()
if (data) setIsCompleted(data.is_complete)
} catch (error) {
console.log('error', error)
}
}
四个操作全部走 Supabase 客户端自动生成的 PostgREST API(supabase.from('todos')),不需要手写任何服务端路由。仓库中的 pages/api/hello.ts 仅为 Next.js 脚手架默认的示例接口,与本例业务无关——也从侧面说明:此应用中完全没有"业务后端"。
数据安全的核心:Postgres Row Level Security
本示例之所以能在"零后端代码"的前提下保证数据安全,依靠的是 Postgres 自带的 Row Level Security(RLS),而非应用层鉴权。README 中将其定义为一种"非常高层级的授权机制",其原理链条如下:
- 每次在 Supabase 创建 Postgres 数据库时,平台会自动填充
authschema 及若干辅助函数。 - 用户登录后,Supabase Auth 签发一个 JWT,其中携带角色
authenticated和该用户的 UUID。 - 客户端后续请求都携带此 JWT,PostgREST 层据此把 SQL 查询切到该用户的身份执行。
- 借助这些身份信息,RLS 策略可以对"每个用户能做什么、看到什么"做细粒度控制。
建表与四条策略(与仓库迁移文件完全一致)
下面的 SQL 是 README 中精简后的核心 schema,与仓库迁移 supabase/migrations/20230712094349_init.sql 内容一一对应(完整可运行版本见该文件):
create table todos (
id bigint generated by default as identity primary key,
user_id uuid references auth.users not null,
task text check (char_length(task) > 3),
is_complete boolean default false,
inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
);
alter table todos enable row level security;
create policy "Individuals can create todos." on todos for
insert with check ((select auth.uid()) = user_id);
create policy "Individuals can view their own todos. " on todos for
select using ((select auth.uid()) = user_id);
create policy "Individuals can update their own todos." on todos for
update using ((select auth.uid()) = user_id);
create policy "Individuals can delete their own todos." on todos for
delete using ((select auth.uid()) = user_id);
逐项拆解:
| 部分 | 含义 |
|---|---|
id bigint generated by default as identity primary key |
自增主键,generated by default 允许显式指定值(与前端 Insert 类型中 id?: number 对应) |
user_id uuid references auth.users not null |
外键指向 auth.users,保证每条 Todo 必然归属一个真实 Supabase 用户 |
task text check (char_length(task) > 3) |
数据库级约束:任务文本必须超过 3 个字符(前端的空值 trim 校验是第一道防线,这是第二道) |
is_complete boolean default false |
完成状态默认未完成 |
inserted_at timestamp with time zone default timezone('utc'::text, now()) not null |
创建时间,统一存 UTC 时区 |
alter table todos enable row level security; |
开启 RLS。默认情况下表对所有角色关闭访问,必须显式创建策略才会放行 |
with check(INSERT) |
校验"写入的新行"是否满足条件:auth.uid() 必须等于 user_id,防止伪造他人 user_id 插入 |
using(SELECT/UPDATE/DELETE) |
校验"被操作的行"是否属于当前用户:只影响 user_id = auth.uid() 的行 |
auth.uid() 是 Supabase 在 auth schema 中提供的辅助函数,等价于从当前请求 JWT 中解析出的用户 id((select auth.uid()) 与 auth.uid() 两种写法在仓库两个版本的 SQL 中均可读到,语义一致)。当请求带的是 service_role 密钥(绕过 RLS)或尚未登录的 anon 请求(无用户身份)时,这些策略分别产生"全通"或"全阻"的结果——这就是为什么前端代码里不需要写 where user_id 也能保证数据隔离,也是为什么 secret 密钥绝不能下发到浏览器。
关于角色补充:本地开发时 CLI 启动的 Auth 用
.env.example中的演示 JWT(role: anon),因此未登录状态可以连通 API;一旦用户登录,SDK 自动用带authenticated角色的用户 JWT 替换之。若想在本地复现邮件确认流程,可访问 inbucket 的 Web 界面(端口 54324)查看"已发送"的验证邮件。
本地运行与目录速查
如需在本地完整跑通该示例,可参考 examples/todo-list/nextjs-todo-list/package.json 中的脚本:
# 安装依赖(next 13、react 18、@supabase/ssr、@supabase/supabase-js、tailwindcss 等)
npm install
# 开发模式:并行走 Tailwind 编译与 Next dev server
npm run dev
# 生产构建(构建前编译 Tailwind 为压缩产物)
npm run build && npm run start
补充要点:
npm run dev实际执行concurrently "npm run dev:css" "next dev",其中dev:css用tailwindcss -w -i ./styles/tailwind.css -o styles/app.css把 styles/tailwind.css 实时编译到 styles/app.css;构建时则使用压缩模式build:css。- Tailwind 的内容扫描范围在 tailwind.config.js 中限定为
./components与./pages。 - 若需本地数据库而非远端项目,可在项目根执行
supabase start,随后 API 位于http://127.0.0.1:54321、Studio 位于http://127.0.0.1:54323,与 .env.example 中的默认地址一致。
至此,示例应用包含的全部关键文件路径汇总如下,方便你继续深入研读:
- 部署与配置说明:examples/todo-list/nextjs-todo-list/README.md
- 数据库迁移(建表 + RLS):examples/todo-list/nextjs-todo-list/supabase/migrations/20230712094349_init.sql
- 本地环境配置:examples/todo-list/nextjs-todo-list/supabase/config.toml
- 浏览器客户端初始化:examples/todo-list/nextjs-todo-list/lib/initSupabase.ts
- 数据库类型定义:examples/todo-list/nextjs-todo-list/lib/schema.ts
- 认证与会话管理:examples/todo-list/nextjs-todo-list/pages/index.tsx
- Todo CRUD 组件:examples/todo-list/nextjs-todo-list/components/TodoList.tsx
小结:一条可复用的开发范式
回顾整个示例,可提炼出 Supabase 应用开发的通用范式:
- Schema 即 API:用 SQL 迁移定义表结构,Supabase 自动暴露 REST 端点,前端用类型化客户端消费;
- RLS 即授权层:打开行级安全并围绕
auth.uid()编写using/with check策略,把"数据归属校验"下沉到数据库,杜绝越权; - JWT 即身份:anon 密钥负责匿名访问,登录后自动切换为用户 JWT,安全边界由服务端强制执行;
- CLI + Git 即交付流水线:
link/config push/db push让远端项目配置与迁移可版本化、可重复,配合 Vercel Preview 分支做到"每个 PR 一个隔离的数据库环境"。
本仓库中的 examples/todo-list/sveltejs-todo-list 等同类示例展示了同一套 RLS 模型在不同前端框架下的复用方式。理解了 Next.js 版本,就掌握了 Supabase 全栈开发的公共地基。
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 StartedRust0624
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