“后端不用写”这种事,我以前是不信的。直到我用 Supabase 把一个小型内部工具的后端只用了半天就搭完,才意识到这套“后端即服务”的路子,对独立开发者和前端团队来说,确实能省下大量造轮子的时间。Supabase 这个名字这两年讨论度很高,简单说,它是个开源版的 Firebase 替代品,但底层不是文档数据库,而是直接给你一个完整的 PostgreSQL,还顺手把数据库的实时订阅、用户认证、文件存储、边缘函数都集成好了。这篇文章我会以一个实际演示项目为主线,从建表、CRUD、实时订阅、用户登录到文件上传,一步步带你跑通整个流程,同时把 RLS 这一层权限控制的坑也拆开讲清楚。如果你正打算给 Next.js、Vue 或者小程序做后端,或者想找个自部署方案的参考,这篇内容应该能帮你少走几条弯路。
1. 内容整体设计与思路拆解
1.1 为什么我选 Supabase 而不是自己写后端
我先说下这个演示项目的背景:我要做一个团队共享的“任务看板”,成员可以登录、创建任务、修改任务状态,还能上传附件。传统做法是写 Node.js/Go 服务,提供 REST API,再接一个 MySQL/PG 数据库,同时要管用户密码加密、Token 生成、权限校验,一套下来少说两三周。Supabase 的做法是,把数据库、认证、实时推送这些底座能力直接以 SDK 形式给出来,前端拿到的是一堆“让代码自动运行”的服务,而不是要自己维护的服务器进程。
这里有个关键认知:Supabase 不是一个“低代码平台”,它不限制你写高级逻辑。它给你的是一套托管好的 Postgres 实例,外加官方封装好的客户端 SDK。Postgres 本身是当今功能最强大的开源数据库,所以 Supabase 的起点非常高——你能用 SQL 写存储过程、触发器、视图,也可以用 REST API 直接操作表,还能通过 Realtime(实时)功能监听表变化。对我来说,选它最大的动力是:团队里前端技术栈是 React,但我不想再维护一套后端 CI/CD、API 网关这类基础设施,Supabase 把基础设施的部分接管了,我只需要关注业务数据表和权限规则。
1.2 核心功能一览及适用场景
Supabase 的功能模块可以分成四大部分:
- 数据库(Database):基于 PostgreSQL,提供了可视化表格界面、SQL 编辑器、自动生成 REST API,还支持 Row Level Security 行级安全策略。
- 身份认证(Auth):支持邮箱密码、手机验证码、邮箱魔法链接,以及 Google、GitHub、微信等 OAuth 登录,管理用户会话和 Token 刷新。
- 实时能力(Realtime):通过 WebSocket 订阅数据库表的 INSERT / UPDATE / DELETE 事件,也能订阅 Postgres Changes。非常适合聊天、协作编辑、实时看板这类场景。
- 存储(Storage):基于 S3 协议的对象存储,可以上传图片、文档、音视频,并提供带权限约束的临时访问签名 URL。
- 边缘函数(Edge Functions):基于 Deno 的 serverless 函数,可以写一些需要跑在服务端的逻辑,比如处理 Webhook、调用第三方 API。
适合它的场景很明确:内部工具、MVP 产品、原型演示、中小型 SaaS 的起步阶段。如果你的项目涉及非常复杂的事务逻辑、大量二进制流处理,或者已经在用一套成熟的微服务架构,Supabase 未必是唯一解,但做一个“能上线、可迭代”的应用,它绝对够用。
1.3 和 Firebase 的横向对比
很多人拿 Supabase 和 Firebase 比较,我两个都用过,说下个人感受。Firebase 的 Firestore 是 NoSQL 文档数据库,上手快,但数据结构一旦复杂,多表关联就非常难受;而 Supabase 直接是关系型数据库,有外键、有事务,天然适合业务逻辑成体系的项目。另外 Supabase 是开源项目,意味着你能自己部署,数据都在你的服务器上,这对很多公司来说是很重要的考量点。但 Supabase 在国内的默认访问速度不一定理想,而且新手起步时会觉得它“太像数据库”了——需要懂 SQL、懂表关系、懂权限规则,不像 Firebase 的规则那样常见的 allow read/write 比较简单。我的态度是:如果你本身熟悉 SQL,选 Supabase 会如鱼得水;如果你完全没有后端概念,Firebase 可能更轻松,但深水区还是需要知识积累。
2. 核心细节解析与实操要点
2.1 项目创建与连接参数
演示的第一步,去 supabase.com 注册一个账号,创建一个新项目。需要填项目名称、数据库密码和 Region。这里注意,Region 一定要选离你用户最近的区域,但国内访问没有特别近的节点,可以选 Singapore 这类网络相对稳定的位置。创建完成后,进入项目 Dashboard,左侧菜单有 Table Editor、SQL Editor、Auth、Storage、Edge Functions,对应不同模块。
项目创建好后,你在Project Settings → API Keys里能找到两个关键信息:URL(http://xxxx.supabase.co)和 anon key(匿名公钥)。anon key 会在客户端的createClient初始化时用到,别看它叫“匿名”,它内部包含 JWT,可以在不登录状态下访问数据库的公开数据——真正限制你要不要暴露数据,靠的是 RLS 策略。
import { createClient } from '@supabase/supabase-js' const supabase = createClient( 'https://your-project.supabase.co', 'your-anon-key' )提示:anon key 是公开的,所以千万不要用它来当“私密钥匙”。任何权限过滤都要依赖数据库层面的 RLS 策略来完成,不要依赖 client 端隐藏。
2.2 建表前的数据建模思路
既然底层是 Postgres,就按照关系型数据库的习惯来建模。任务看板的核心表是tasks和profiles。profiles用来扩展auth.users里的用户资料,比如昵称、头像等。任务表字段可以包括:id、title、description、status、assignee_id、creator_id、created_at、updated_at、due_date、attachments。
这里有一个新手常犯的错误:直接把用户邮箱作为外键存放在业务表里。邮箱这是可变的,而且auth.users里的 email 字段并不适合直接关联。正确做法是使用auth.uid()获取当前登录用户的 UUID 作为关联。所以assignee_id和creator_id都应该是 UUID 类型,并参考auth.users (id)外键。
在 Supabase 的表编辑器里可以直接新建表,也可以用 SQL。我更推荐把建表语句保存在 SQL 文件里,方便在另一个项目里复现。先打开SQL Editor,执行以下脚本:
-- 创建个人资料表,关联 auth.users create table if not exists public.profiles ( id uuid references auth.users (id) on delete cascade primary key, display_name text, avatar_url text, created_at timestamptz default now() ); alter table public.profiles enable row level security; -- 任务表 create table if not exists public.tasks ( id uuid primary key default gen_random_uuid(), title text not null, description text, status text not null default 'todo' check (status in ('todo', 'in_progress', 'done')), assignee_id uuid references public.profiles (id), creator_id uuid references public.profiles (id), due_date date, created_at timestamptz default now(), updated_at timestamptz default now() ); alter table public.tasks enable row level security; -- 自动更新 updated_at create or replace function public.handle_updated_at() returns trigger language plpgsql as $$ begin new.updated_at = now(); return new; end; $$; create trigger tasks_set_updated_at before update on public.tasks for each row execute function public.handle_updated_at(); -- 为新用户创建 profile 的触发器 create or replace function public.handle_new_user() returns trigger language plpgsql security definer set search_path = public as $$ begin insert into public.profiles (id, display_name, avatar_url) values (new.id, new.raw_user_meta_data->>'display_name', new.raw_user_meta_data->>'avatar_url'); return new; end; $$; create trigger on_auth_user_created after insert on auth.users for each row execute procedure public.handle_new_user();这段 SQL 里包含了两个触发器:一个是自动更新任务的修改时间,另一个是在新用户注册后自动往profiles表插一条记录。这一步非常重要,否则你会发现用户登录后,用户信息表是空的——前端还得手动补插,容易出错。
2.3 RLS 策略的必要性
建表语句里我特别加了enable row level security。很多人会问:Supabase 不是自带 API 吗,为什么还要做这么一层?因为为了让客户端直接使用 anon key 访问数据库,Supabase 的 REST API 是“直通”行级数据的,如果没有 RLS,任何拿到 anon key 的人都能读取甚至修改所有表的数据。这是致命的。
RLS 可以理解为 Postgres 在查询执行前加了一道“条件过滤”。比如任务列表的 RLS,我希望登录用户只能看到“自己是创建者或指派对象”的任务,那就得写这样的策略:
create policy "用户可以查看与自己相关的任务" on public.tasks for select using ( auth.uid() = creator_id or auth.uid() = assignee_id );同样,写操作也要定义策略。例如只有创建者能更新任务,以及只有创建者或管理员能删除任务。这里的“管理员”我们可以用 profiles 表的一个role字段来定义,但为了演示,我一直保持简单——“创建者或执行者都可更新”。要注意的策略语法中auth.uid()返回当前用户 UUID,如果用户未登录,这个函数会返回 NULL,策略就会自然失效,也保证了数据安全。
2.4 安装 SDK 与环境变量
演示项目我直接用 Vite + React。在项目根目录安装官方包:
npm install @supabase/supabase-js然后建议把 URL 和 anon key 放到.env.local文件中,避免把密钥硬编码到源码里:
VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_ANON_KEY=your-anon-key注意,Vite 项目读取环境变量需要以VITE_前缀开头。然后在src/lib/supabase.js中初始化客户端。
import { createClient } from '@supabase/supabase-js' const supabaseUrl = import.meta.env.VITE_SUPABASE_URL const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY export const supabase = createClient(supabaseUrl, supabaseAnonKey)这里要提醒一个细节:supabase-js默认会建议使用autoRefreshToken,它会根据 JWT 过期时间自动刷新。开发模式下,如果浏览器缓存了旧的本地状态,可能出现登录失效半天不清醒的情况,保留默认设置基本没问题,但如果你做的是 React Native 或小程序,务必看一下文档里关于 AsyncStorage 的接入配置。
3. 实操过程与核心环节实现
3.1 基础 CRUD 操作演示
咱们先在 UI 里做最基础的增删改查。先看查询任务列表,不仅要把 tasks 表查出来,还要把 assignee 和 creator 的 profile 信息一次性 join 出来。Supabase 的查询语法挺直观:
const { data, error } = await supabase .from('tasks') .select('*, assignee:assignee_id(display_name, avatar_url), creator:creator_id(display_name, avatar_url)') .order('created_at', { ascending: false }); if (error) console.error(error);这里用了别名语法,assignee:assignee_id(...)表示把assignee_id外键关联到 profiles 表,并选择display_name和avatar_url字段。返回结果里会多出assignee和creator两个对象,方便前端渲染人名和头像。
新增任务的时候,注意要把creator_id设置为当前登录用户的 ID,通过supabase.auth.getUser()来获取:
const { data: userData } = await supabase.auth.getUser(); const userId = userData.user.id; const { data, error } = await supabase .from('tasks') .insert([ { title: '开发登录页面', description: '使用 Supabase Auth', status: 'todo', creator_id: userId, assignee_id: userId } ]) .select();钩子点:insert后加.select(),这样才能返回插入后的完整行数据(包括默认生成的 id 和 created_at),很多新手会忽略这个,导致刚刚插入后无法拿 ID 做下一步操作。
更新和删除也很简单:
// 更新状态 const { error } = await supabase .from('tasks') .update({ status: 'in_progress' }) .eq('id', taskId); // 删除 const { error } = await supabase .from('tasks') .delete() .eq('id', taskId);如果操作报 403 或 42501,大概率是 RLS 策略没写好。做更新操作时会触发using和with check两个条件,简单理解是using是“能否操作原有数据”,with check是“插入/更新后的新值能否满足条件”。两个条件都要过。
3.2 实时订阅实现看板自动刷新
实时功能是一个亮点。在任务看板中,多人同时操作,页面上如果手动刷新很蠢。用 Supabase 的channel来订阅任务表的变化:
const channel = supabase .channel('public:tasks') .on('postgres_changes', { event: '*', schema: 'public', table: 'tasks' }, (payload) => { console.log('变化: ', payload); // 根据 payload.new 或 payload.old 更新本地状态 }) .subscribe();这样只要任何客户端对 tasks 表做了 INSERT、UPDATE、DELETE,都会实时推送到订阅者。需要恢复旧事件时,在 useEffect 里:
useEffect(() => { const channel = supabase .channel('schema-db-changes') .on('postgres_changes', { event: '*', schema: 'public', table: 'tasks' }, handleChange) .subscribe(); return () => { supabase.removeChannel(channel); }; }, []);注意,订阅前建议先拉取一次全量数据,再用实时事件做增量更新,避免丢数据。我做这个小项目时,专门写了一个applyChange函数:如果是 INSERT,把 payload.new 加入 state;UPDATE,替换对应 id 的数据;DELETE,从 state 里移除。如果直接“每次都重新查询全表”,会频繁触发数据库压力,尤其在多人使用时。
我建议实时订阅只在天真无邪的场景里用。付费功能限制方面,Supabase 的免费层级对 Realtime 并发连接数有限制(默认 200 个在线连接左右),如果是在国内公网服务器上用,注意一下长连接被防火墙切断的问题,我会在避坑部分详细说。
3.3 Auth 登录注册功能与用户状态管理
Supabase Auth 用起来很直接,注册:
const { data, error } = await supabase.auth.signUp({ email: 'user@example.com', password: 'password123', options: { data: { display_name: '张三' } } });如果项目里开启了邮件确认,用户会收到一封确认邮件。SignUp 成功后,默认情况不会自动创建 session,而是返回一个只有 user 没有 session 的对象。这在很多新手演示里容易造成困惑——明明注册成功了,为什么前端登录状态不对?所以要提示用户去邮箱确认,或者在后端的 Auth 设置里关闭“确认邮箱”选项,才能实现注册即登录。
登录:
const { data, error } = await supabase.auth.signInWithPassword({ email: 'user@example.com', password: 'password123' });登录成功后,data.session里有 access_token,我们会把它存储到本地,Supabase 客户端会自动处理后续的请求带上 Authorization 头。封装一个简单的 AuthContext 来监听登录状态:
supabase.auth.getSession().then(({ data }) => { setSession(data.session); }); supabase.auth.onAuthStateChange((_event, session) => { setSession(session); });手动登出:
const { error } = await supabase.auth.signOut();一个有趣的点是,如果你想在服务端渲染框架里读用户信息,可以用getUser()代替getSession(),getUser会向 Auth 服务发送请求验证 token,更安全。但在客户端,getSession更快,因为 token 就在本地,不过它可能有篡改风险,我们后续在 RLS 里都依赖auth.uid()来验真,所以问题不大。
3.4 用户资料实时联动
刚才建表时,我们创建了一个触发器,注册后自动把用户数据放进profiles表,那前端怎么读取当前用户资料?可以这样:
const { data: profile, error } = await supabase .from('profiles') .select('*') .eq('id', userId) .single();而任务列表中的assignee_id关联到 profiles 表后,我们就能拿到 assignee 的名字和头像。如果头像的更新是实时同步的,还可以订阅 profiles 表的变化,达到类似“用户头像更新后全端同步”的效果。
3.5 存储模块:附件上传与公开/私密访问
任务看板里我支持上传图片附件。Supabase Storage 很方便,创建 bucket 时选择公开或私有。对于“脏文件 = 用户头像”这类内容,我建议私有 bucket,因为后续还要接 RLS 控制谁能访问。创建 bucket 可以在 Dashboard 里点,也可以通过 SDK:
const { data, error } = await supabase.storage.createBucket('attachments', { public: false, // 私有 allowedMimeTypes: ['image/*', 'application/pdf'], fileSizeLimit: 10 * 1024 * 1024 // 10MB });上传文件的核心 API:
const filePath = `${userId}/${Date.now()}-${file.name}`; const { error } = await supabase.storage .from('attachments') .upload(filePath, file, { cacheControl: '3600', upsert: false });上传成功后,如果需要私有 bucket 内的文件预览,不能直接用getPublicUrl,而要生成一个临时签名 URL:
const { data } = await supabase.storage .from('attachments') .createSignedUrl(filePath, 60 * 60); // 一小时有效 // data.signedUrl 就是带限时 token 的地址对于私有 bucket 的访问,Supabase 有storage.objects表的 RLS 可以配置,比如“只有任务参与者可以下载附件”。我写了一个简单的策略,允许创建该附件的用户读取:
create policy "允许用户访问自己的附件" on storage.objects for select to authenticated using (bucket_id = 'attachments' and owner = auth.uid());这里的owner是 storage.objects 表自动记录的上传者 ID,有了这层,即使生成了签名 URL,别人也无法绕过策略访问。
3.6 边缘函数:用 Deno 写点服务端逻辑
有时候我们需要在服务端调一些外部 API 或者生成数据,那就用 Edge Functions。Supabase 的 CLI 可以本地写函数,然后部署上去。先安装 CLI:
npm install -g supabase supabase login supabase link --project-ref your-project-ref创建函数时,在项目目录执行:
supabase functions new hello-world生成的functions/hello-world/index.ts模板长这样:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' serve(async (req) => { const { name } = await req.json() return new Response(JSON.stringify({ message: `Hello ${name}!` }), { headers: { 'Content-Type': 'application/json' } }) })部署:
supabase functions deploy hello-world我可以在这里实现“任务导出为 PDF”之类的需求,但是要注意,Edge Functions 默认是匿名可访问的,如果你的函数里需要拿到当前用户身份,要到 Authorization 头里解析 supabase JWT。官方提供了supabase-js在 Deno 环境下的用法,我建议用createClient配合auth.getUser()校验用户身份,不要在函数里盲目信任外部参数。
但在这一步我只做一个相对简单的“获取公开任务数量”函数,用来验证函数调用链路。前端要用 fetch 直接打函数的 URL,并附带上 anon key 作为apikey头:
const res = await fetch('https://your-project.supabase.co/functions/v1/hello-world', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${supabaseAnonKey}` }, body: JSON.stringify({ name: 'Supabase User' }) });记得设置Authorization头时,如果是登录用户,可以带上 access_token,否则就用 anon key。很多人在本地测试时,没部署 Edge Function,直接打 URL 会 404,记得确认函数已经部署成功。
4. 常见问题与排查技巧实录
4.1 “明明设置了 RLS,外层服务怎么还能查数据?”
这个问题非常典型。你在建表时启用了 RLS,但客户端 SDK 依然能读取所有数据,通常是两种原因。第一,你建表时没启用 RLS,只写了 policy 但没有执行enable row level security。RLS 默认对表所有者是放行的,所以当 Supabase 的 API 内部是服务角色去访问时,就会绕过所有策略——服务角色相当于“超级管理员”,专用于后端服务,不建议前端使用。第二,你的 policy 是给anon角色创建的,而不是authenticated。如果表里存的是售货数据,要么强制用户登录,要么给 anon 角色写策略。
在 Supabase 的 SQL 编辑器,可以用这个查询来检查表的 RLS 是否打开:
select tablename from pg_tables where schemaname = 'public';更直观的是 Dashboard 的 Table Editor 里,表行旁边会看到绿标/红标标识 RLS 状态。
4.2 数据库连接时好时坏,实时通道经常断开
Supabase 的 Realtime 用的是 WebSocket,部分企业网络环境里,长连接可能被闲置超时切断。如果你发现channel掉线后不能自动重连,可以考虑优化订阅方式,或者做一个“心跳”逻辑:在 channel 事件里监听presence变化,或者定期执行一个轻量的数据库查询来保持连接活性。但更稳妥的是把 Realtime 和 REST 结合——先保证重要数据通过普通 HTTP 拉取,实时只是辅助,这样即使断线,关键功能也不会瘫痪。
我遇到过的问题:本地开发时很顺畅,一部署到公网,Realtime 就隔几分钟断一次。原因可能是服务器侧的 NAT、代理设了空闲超时。可以改用心跳包的方式强制续命:
setInterval(() => { supabase.channel('heartbeat').send({ type: 'broadcast', event: 'ping', payload: {} }) }, 30000)但这会增加成本,务必在正式环境按需使用。最省心的做法是仅在需要协作功能的界面才订阅实时,不要把页面所有数据都依赖实时。
4.3 认证流程中的“注册后不登录”问题
很多朋友用signUp后直接跳转到用户主页,结果发现data.session是 null。刚才说过,这是因为你开启了“邮件确认”。如果希望用户注册后免登录,在 Supabase Dashboard 的 Authentication → Providers → Email 里,关闭 “Confirm email” 即可。但在生产环境,我强烈建议保留确认邮件,一是防止垃圾注册,二是校验邮箱有效性,否则有人随便输入假邮箱就能注册账号。
另外,用邮箱密码登录时,如果密码错误会返回错误信息Invalid login credentials,这是正常的,但是如果用户输入邮箱大小写不一致,可能也导致登录失败。建议在登录表单里对邮箱做.trim().toLowerCase()处理,并且在注册时存小写邮箱。
4.4 查询性能优化与常见坑
Postgres 本身很强,但如果没有加索引,用户量大起来查询会变慢。演示阶段无所谓,但正式建议给外键字段加索引:
create index tasks_assignee_idx on public.tasks (assignee_id); create index tasks_creator_idx on public.tasks (creator_id); create index tasks_status_idx on public.tasks (status);另一个大坑是select('*')会把大字段也查出来,比如 description 很长、附件路径很多。尽量只 select 所需字段。还有,如果在select里关联了profiles表,注意只取必要的字段,否则一个任务列表可能查询数百行,网络传输慢。
4.5 本地开发中的 Migrations 管理
Supabase 不只是线上服务,它支持通过 CLI 把数据库 schema 用 migration 文件管理起来。在项目的supabase/migrations目录下创建 SQL 文件,然后执行supabase db push就能把结构变更同步到远端数据库。强烈建议从一开始就用版本化 SQL,而不是直接在线改表,因为团队里需要环境同步,否则 A 的本地改完,B 的数据库还是一头雾水。
不过官方 CLI 需要 Docker 支持本地服务,如果你不想装 Docker,也可以直接在 Dashboard 的 SQL 编辑器执行并保存脚本。但写成 migration 最大的好处是你的整个建表过程可以被代码审查、回滚、复制。
5. 经验与心得:几个被你忽略的细节
5.1 数据库函数和触发器是真正的“秘密武器”
有人觉得 Supabase 无非是一个“表单直连数据库的工具”,其实它背后的 Postgres 能力极其强大。就拿自动给新用户生成 profile 来说,那是我最满意的策略之一。所有依赖数据库确保“数据完整性”的逻辑,不要放在应用代码里,而是放在数据库触发器里。这样即使用户通过移动 App、小程序、管理后台多个入口注册,都能保持一致。再比如任务更新的updated_at,如果用应用层代码设置,那每次要重写代码,但用触发器就一劳永逸。
5.2 培养“先设 RLS,再写业务”的习惯
在 Supabase 项目里,所有跟数据有关的操作的第一步就是设置 RLS。如果表的 RLS 没建好,就别碰业务逻辑。我见过不少团队在初期用 anon key 调接口,数据裸奔了几个月才发现,等用户量上来再补策略时已经晚了不少。建议刚启一个表,立即空写一个极端保守的策略“只允许 authenticated 且 uid 匹配”的策略,后面再放宽。
5.3 善用 Dashboard 的查询日志
遇 到问题不要瞎猜,Supabase Dashboard 的 Logs 面板可以看数据库、Auth、Realtime 的日志。点击某个请求还能看到 SQL 本体,这对排查“为什么这条查询被拒绝”非常有效。比如你会看到一行日志里带着new row violates row-level security policy,就知道是 RLS 的with check没通过,而不是代码逻辑 bug。
5.4 一个用于演示的完整代码结构
最后,把演示项目的目录结构写出来,算是一个可抄作业的参考:
src/ components/ TaskCard.jsx TaskList.jsx AuthForm.jsx lib/ supabase.js hooks/ useAuth.js useTasks.js pages/ Dashboard.jsx Login.jsx任务列表页的流程:挂载时拉取任务,然后订阅 tasks 表变化;按钮触发更新、删除时调用 SDK 对应方法;附件上传先调用 Storage 拿到文件路径,再更新任务表的 attachments 字段。整体代码量很轻,但功能完整。
我自己在本地搭这套东西的时候,最大的体验是:Supabase 把“做后端”这件事的门槛降到极致,同时又留有足够的深度。免费层级的限制对个人项目来说绝对够用,如果以后用户增长,可以平滑切到按量付费或自托管,生态和服务稳定度也越来越好。如果你正处在“想做个自己作品但不想写一堆服务器代码”的阶段,我建议直接拿这个演示项目开刀,跑一遍下来,后端的基本功力也就练出来了。