首页
/ Supabase 实战:用 Next.js + Postgres RLS 从零构建多用户 Todo 应用

Supabase 实战:用 Next.js + Postgres RLS 从零构建多用户 Todo 应用

2026-09-06 19:02:43作者:范垣楠Rhoda

本教程基于开源仓库中的官方示例 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)
  • anon public key(客户端密钥,新版客户端中对应 publishable key 语义)

说明anon key 是客户端密钥,它只允许对数据库进行"匿名访问";当用户登录后,请求会自动切换为携带用户自身登录令牌(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 把本地仓库与远程项目对接,实现"配置即代码 + 迁移即代码"。

  1. 在 Supabase Dashboard 创建或选择一个项目。
  2. 复制并填充 dotenv 模板:
cp .env.production.example .env.production
  1. 链接本地项目,并把本地配置与远端合并:
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 中找到。

  1. 同步项目配置(Auth 设置、API 设置等,来自 supabase/config.toml):
SUPABASE_ENV=production npx supabase@latest config push
  1. 同步数据库结构(把 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_urladditional_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 项目。这样,数据库迁移或服务配置可以先在独立环境中验证,确认无误后再应用到生产,避免"改一处配置影响线上"。

操作步骤:

  1. 确保 Vercel 项目已关联 Git 仓库。
  2. 在 Vercel 中为 Preview 环境配置以下环境变量:
    • NEXT_PUBLIC_SUPABASE_URL
    • NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
  3. 新建一个 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.tomlenable_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 三类结构(idinserted_atis_completetaskuser_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 中将其定义为一种"非常高层级的授权机制",其原理链条如下:

  1. 每次在 Supabase 创建 Postgres 数据库时,平台会自动填充 auth schema 及若干辅助函数。
  2. 用户登录后,Supabase Auth 签发一个 JWT,其中携带角色 authenticated 和该用户的 UUID。
  3. 客户端后续请求都携带此 JWT,PostgREST 层据此把 SQL 查询切到该用户的身份执行。
  4. 借助这些身份信息,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:csstailwindcss -w -i ./styles/tailwind.css -o styles/app.cssstyles/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 中的默认地址一致。

至此,示例应用包含的全部关键文件路径汇总如下,方便你继续深入研读:

小结:一条可复用的开发范式

回顾整个示例,可提炼出 Supabase 应用开发的通用范式:

  1. Schema 即 API:用 SQL 迁移定义表结构,Supabase 自动暴露 REST 端点,前端用类型化客户端消费;
  2. RLS 即授权层:打开行级安全并围绕 auth.uid() 编写 using/with check 策略,把"数据归属校验"下沉到数据库,杜绝越权;
  3. JWT 即身份:anon 密钥负责匿名访问,登录后自动切换为用户 JWT,安全边界由服务端强制执行;
  4. CLI + Git 即交付流水线link/config push/db push 让远端项目配置与迁移可版本化、可重复,配合 Vercel Preview 分支做到"每个 PR 一个隔离的数据库环境"。

本仓库中的 examples/todo-list/sveltejs-todo-list 等同类示例展示了同一套 RLS 模型在不同前端框架下的复用方式。理解了 Next.js 版本,就掌握了 Supabase 全栈开发的公共地基。

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