Supabase + Next.js 全端實戰:從零打造協作看板 | Supabase 完整教學
這是 Supabase 系列的全端實戰篇。過去四十六篇我們把資料庫、RLS、Auth、Storage、Realtime、型別生成一塊一塊拆開講透;這一篇不再教新元件,而是把它們組裝成一個真正跑得起來的產品——用 Supabase + Next.js 打造一個多人協作看板(Kanban)。我們會從建 schema 與 RLS 開始,接上
@supabase/ssr的登入、寫受 RLS 保護的 CRUD、加上 Storage 上傳頭像與附件,最後用 Realtime 讓多人畫面即時同步。每一段都附可直接執行的 SQL 與 TypeScript。
前言
前面四十六篇像是在逐一介紹樂高的每一種積木:方塊、輪子、齒輪、馬達。這一篇,我們要把它們拼成一台真正能跑的車。成品是一個協作看板(Collaborative Kanban)——類似 Trello 的簡化版:使用者登入後可以建立看板(board),看板裡有多個清單(list,例如「待辦 / 進行中 / 完成」),清單裡有卡片(card);看板可以邀請其他成員一起協作,任何人拖動卡片、新增卡片,所有線上成員的畫面都會即時同步;卡片可以附加圖片附件,使用者也能上傳自己的頭像。
這個小小的成品,剛好會用到系列前面教過的每一種核心能力:
- 資料庫 schema 與外鍵(《資料庫與 Postgres》篇):boards、lists、cards、board_members 四張表的關聯設計
- RLS 政策(《RLS 與 Policies》篇):用 board_members 中介表控制「誰能看到哪個看板」
- Auth 登入(《Auth 驗證系統》篇),但這次搭配
@supabase/ssr做伺服器端 session - 受 RLS 保護的 CRUD(《客戶端 SDK》篇):用 supabase-js 做 insert/update/delete
- Storage 上傳(《Storage 物件儲存》篇):頭像與卡片附件
- Realtime 即時同步(《Realtime 即時訂閱》篇):多人看板的 Postgres Changes 訂閱
- TypeScript 型別(《型別生成》篇):用
gen types讓整條資料流型別安全
本篇你會學到:
- 架構怎麼拆:一個全端 App 的資料流,如何在 schema、RLS、Auth、Storage、Realtime 之間分工
- 逐段 build:每個能力都給你可執行的 SQL 或 TypeScript,照著貼就能跑
- 整合的坑:單獨學每個元件時不會遇到、但組起來才會爆的問題(SSR session、Realtime 授權、RLS 沒對上)
- 把最佳實踐落地:上一篇《最佳實踐與反模式》的清單,在這個真實專案裡怎麼一條條兌現
架構拆解
在貼任何程式碼之前,先把這台「車」的結構圖畫清楚。全端 App 的難處從來不是單一元件,而是元件之間怎麼接。
資料流全景
┌─────────────────────────── Next.js (App Router) ───────────────────────────┐
│ │
│ 瀏覽器 Client Component Server Component / Route Handler │
│ ├─ createBrowserClient ├─ createServerClient(cookies) │
│ ├─ CRUD (anon key, 受 RLS) ├─ 首屏 SSR 撈資料 (受 RLS) │
│ ├─ Storage 上傳 └─ getUser() 驗證身份 │
│ └─ Realtime 訂閱 (WSS) │
│ │ middleware:每次請求刷新 token、寫回 cookie │
└────────────────────┼────────────────────────────────────────────────────────┘
│ HTTPS (anon key) / WSS
┌────────────▼─────────────┐
│ Supabase API Gateway │
│ ├─ /auth → GoTrue │ ← 登入 / session
│ ├─ /rest → PostgREST │ ← CRUD,每筆查詢都過 RLS
│ ├─ /storage→ Storage API │ ← 頭像 / 附件,受 Storage RLS
│ └─ /realtime→ Realtime │ ← 看板即時同步,受 Realtime 授權
└────────────┬─────────────┘
▼
PostgreSQL(RLS 是唯一守門員)
boards ─< lists ─< cards
boards ─< board_members >─ auth.users
一句話總結這張圖:PostgreSQL 的 RLS 是整個 App 唯一的安全邊界。不管請求走 REST、Storage 還是 Realtime,最終都要通過同一套 RLS 政策——這正是上一篇《最佳實踐與反模式》反覆強調的「前端直連資料庫、由資料庫當守門員」。
五個元件如何組合
| 元件 | 在看板 App 裡的角色 | 對應系列文章 |
|---|---|---|
| Schema + 外鍵 | 定義 boards / lists / cards / board_members 的關聯 | 資料庫與 Postgres 篇 |
| RLS | 「只有看板成員能讀寫該看板的資料」的政策 | RLS 與 Policies 篇 |
| Auth(@supabase/ssr) | 登入、SSR session、middleware 刷新 token | Auth 驗證系統 篇 |
| CRUD(supabase-js) | 建看板、加卡片、拖動卡片(改 list_id) | 客戶端 SDK 篇 |
| Storage | 使用者頭像、卡片圖片附件 | Storage 物件儲存 篇 |
| Realtime | 多人同時操作時,畫面即時同步 | Realtime 即時訂閱 篇 |
接下來我們按照真實開發的順序逐段 build:先 schema、再 RLS、再 Auth、再 CRUD、再 Storage、最後 Realtime。這個順序不是隨意的——資料結構是地基,安全政策必須在資料還沒被前端碰到之前就立好。
逐段實作
第一段:建 schema(boards / lists / cards / board_members)
看板的資料模型是一個典型的一對多鏈:一個 board 有多個 list,一個 list 有多個 card;board 與 user 之間則是多對多(一個看板多個成員、一個使用者多個看板),用 board_members 中介表表達。
-- 看板:owner_id 記錄建立者
create table public.boards (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users on delete cascade,
title text not null check (char_length(title) between 1 and 80),
created_at timestamptz not null default now()
);
-- 看板成員(多對多中介表):RLS 判斷「誰能存取哪個看板」的核心
create table public.board_members (
board_id uuid not null references public.boards on delete cascade,
user_id uuid not null references auth.users on delete cascade,
role text not null default 'member' check (role in ('owner', 'member')),
created_at timestamptz not null default now(),
primary key (board_id, user_id)
);
-- 清單(待辦 / 進行中 / 完成):position 用來排序
create table public.lists (
id uuid primary key default gen_random_uuid(),
board_id uuid not null references public.boards on delete cascade,
title text not null,
position int not null default 0,
created_at timestamptz not null default now()
);
-- 卡片:list_id 決定它在哪個清單(拖動卡片 = 改 list_id + position)
create table public.cards (
id uuid primary key default gen_random_uuid(),
board_id uuid not null references public.boards on delete cascade,
list_id uuid not null references public.lists on delete cascade,
title text not null,
description text,
attachment_path text, -- 對應 Storage 裡的檔案路徑
position int not null default 0,
created_at timestamptz not null default now()
);
-- 效能:RLS 與查詢會頻繁用到的欄位一定要建索引
create index idx_board_members_user on public.board_members (user_id);
create index idx_lists_board on public.lists (board_id);
create index idx_cards_board on public.cards (board_id);
create index idx_cards_list on public.cards (list_id, position);
幾個設計重點:cards 表同時存 board_id 和 list_id,這是刻意的反正規化——RLS 政策直接用 board_id 判斷權限,不必每次都 JOIN 回 lists 再回 boards,省一次關聯查詢。board_members 用複合主鍵 (board_id, user_id) 天然防止重複邀請。所有外鍵都加 on delete cascade,刪看板時清單、卡片、成員一併清掉。
第二段:立 RLS 政策(唯一的守門員)
schema 建好後,下一件事永遠是開 RLS——這是上一篇的鐵律。我們先寫一個 helper 函式判斷「某使用者是不是某看板的成員」,再讓四張表都引用它。
-- 四張表一律開 RLS,啟用後預設全拒絕
alter table public.boards enable row level security;
alter table public.board_members enable row level security;
alter table public.lists enable row level security;
alter table public.cards enable row level security;
-- helper:判斷呼叫者是否為某看板成員(SECURITY DEFINER 繞過 board_members 自身的 RLS,避免遞迴)
create or replace function public.is_board_member(_board_id uuid)
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
select exists (
select 1 from public.board_members
where board_id = _board_id
and user_id = (select auth.uid())
);
$$;
有了 is_board_member,政策就非常好讀:
-- boards:成員可讀;只有本人能建立自己是 owner 的看板
create policy "members can read boards" on public.boards
for select to authenticated
using (public.is_board_member(id));
create policy "users can create own board" on public.boards
for insert to authenticated
with check ((select auth.uid()) = owner_id);
create policy "owner can update board" on public.boards
for update to authenticated
using (public.is_board_member(id) and (select auth.uid()) = owner_id);
-- board_members:成員可看到同看板的其他成員;owner 可新增成員(邀請)
create policy "members can read membership" on public.board_members
for select to authenticated
using (public.is_board_member(board_id));
create policy "owner can add members" on public.board_members
for insert to authenticated
with check (
exists (
select 1 from public.boards b
where b.id = board_id and b.owner_id = (select auth.uid())
)
);
-- lists 與 cards:成員可對「自己是成員的看板」做全部 CRUD
create policy "members manage lists" on public.lists
for all to authenticated
using (public.is_board_member(board_id))
with check (public.is_board_member(board_id));
create policy "members manage cards" on public.cards
for all to authenticated
using (public.is_board_member(board_id))
with check (public.is_board_member(board_id));
還需要一個小觸發器:建立看板時,自動把 owner 加進 board_members,否則建完看板卻不是自己的成員、連自己都讀不到。
create or replace function public.add_owner_as_member()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.board_members (board_id, user_id, role)
values (new.id, new.owner_id, 'owner');
return new;
end;
$$;
create trigger trg_add_owner_as_member
after insert on public.boards
for each row execute function public.add_owner_as_member();
判讀重點:所有政策都用 (select auth.uid()) 包裝觸發查詢級快取(《最佳實踐與反模式》講過的效能技巧);is_board_member 用 security definer 是為了避免「查 board_members 又觸發 board_members 的 RLS」的無窮遞迴——這是自我參照 RLS 最經典的坑。
第三段:Auth 登入(@supabase/ssr 三個 client)
到了 Auth,和單純前端 SPA 最大的不同來了:Next.js App Router 需要在伺服器端也拿得到 session,所以要用 @supabase/ssr 而非只用 @supabase/supabase-js。核心觀念是建三個 client 工廠:瀏覽器端、伺服器端、middleware。
npm install @supabase/supabase-js @supabase/ssr
先是瀏覽器端(給 Client Component 用):
// lib/supabase/client.ts —— 瀏覽器端,給 Client Component
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@/types/database.types'
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! // 前端只用 anon key,受 RLS 保護
)
}
再是伺服器端(給 Server Component / Route Handler 用),重點在把 Supabase 的 session 接到 Next.js 的 cookie 系統:
// lib/supabase/server.ts —— 伺服器端,透過 cookie 讀寫 session
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import type { Database } from '@/types/database.types'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// 在 Server Component 中 set 會拋錯,交給 middleware 處理即可,可忽略
}
},
},
}
)
}
最關鍵、也最容易漏掉的是 middleware——它負責在每一次請求刷新過期的 access token,並把新 cookie 寫回瀏覽器。沒有它,token 一過期整個 App 就會拿到 null user。
// middleware.ts —— 每次請求刷新 token(全端 SSR 的關鍵)
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
response = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
},
},
}
)
// 一定要用 getUser()(向伺服器驗 JWT),它會順帶觸發 token 刷新
const { data: { user } } = await supabase.auth.getUser()
// 未登入且要進看板頁 → 導去登入頁
if (!user && request.nextUrl.pathname.startsWith('/board')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
登入頁本身就用瀏覽器端 client 呼叫 signInWithPassword:
// app/login/actions.ts (Client Component 中呼叫)
const supabase = createClient() // 瀏覽器端
async function signIn(email: string, password: string) {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) throw error
window.location.href = '/board' // 登入後導向看板列表
}
第四段:受 RLS 保護的 CRUD
有了登入,CRUD 就水到渠成——因為 RLS 已經幫我們把關,前端不需要、也不應該再自己過濾權限。先看首屏在 Server Component 用 SSR 撈資料:
// app/board/[id]/page.tsx —— Server Component,首屏 SSR 撈看板資料
import { createClient } from '@/lib/supabase/server'
export default async function BoardPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = await createClient()
// RLS 會自動把「非成員看板」擋掉,查不到就回空
const { data: lists } = await supabase
.from('lists')
.select('id, title, position, cards ( id, title, list_id, position, attachment_path )')
.eq('board_id', id)
.order('position')
return <BoardView boardId={id} initialLists={lists ?? []} />
}
注意這裡用了巢狀 select 一次 JOIN 把 lists 連同 cards 一起帶回來——避開《最佳實踐與反模式》點名的 N+1 反模式。
寫入操作(建看板、加卡片、拖動卡片)則在 Client Component 用瀏覽器端 client:
// 建看板:只要傳 title 與 owner_id,RLS 的 with check 會擋掉冒名建立
async function createBoard(title: string) {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
const { data, error } = await supabase
.from('boards')
.insert({ title, owner_id: user!.id })
.select()
.single()
if (error) throw error
return data // 觸發器已自動把 owner 加進 board_members
}
// 加卡片
async function addCard(boardId: string, listId: string, title: string) {
const supabase = createClient()
const { data, error } = await supabase
.from('cards')
.insert({ board_id: boardId, list_id: listId, title })
.select()
.single()
if (error) throw error
return data
}
// 拖動卡片 = 更新 list_id 與 position(RLS 確保只能改自己有權限的看板)
async function moveCard(cardId: string, toListId: string, position: number) {
const supabase = createClient()
const { error } = await supabase
.from('cards')
.update({ list_id: toListId, position })
.eq('id', cardId)
if (error) throw error
}
insert 後要 .select() 才會回傳資料(supabase-js v2 的預設行為,《客戶端 SDK》篇提過)。整段 CRUD 沒有一行在做權限判斷——權限全交給 RLS,這正是 Supabase 架構的精髓。
第五段:Storage 上傳(頭像與卡片附件)
先在 SQL 建兩個 bucket,並替私有的附件 bucket 立 Storage RLS。頭像設為公開、卡片附件設為私有(只有看板成員能存取)。
-- 建 bucket
insert into storage.buckets (id, name, public) values
('avatars', 'avatars', true),
('card-attachments', 'card-attachments', false)
on conflict (id) do nothing;
-- 頭像:使用者只能上傳到「以自己 uid 命名的資料夾」(avatars/<uid>/xxx.png)
create policy "users upload own avatar" on storage.objects
for insert to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
-- 卡片附件:路徑第一段是 board_id,只有該看板成員能上傳/讀取
create policy "members read attachments" on storage.objects
for select to authenticated
using (
bucket_id = 'card-attachments'
and public.is_board_member(((storage.foldername(name))[1])::uuid)
);
create policy "members upload attachments" on storage.objects
for insert to authenticated
with check (
bucket_id = 'card-attachments'
and public.is_board_member(((storage.foldername(name))[1])::uuid)
);
前端上傳程式碼——注意檔案路徑的設計就是安全的關鍵:把 uid 或 board_id 放在路徑第一段,Storage RLS 才能用 storage.foldername(name) 解析出來判斷權限。
// 上傳頭像:路徑用 <uid>/avatar.png,對應 Storage RLS
async function uploadAvatar(file: File) {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
const path = `${user!.id}/avatar.png`
const { error } = await supabase.storage
.from('avatars')
.upload(path, file, { upsert: true })
if (error) throw error
// 公開 bucket 直接取 public URL 存進 profile
const { data } = supabase.storage.from('avatars').getPublicUrl(path)
return data.publicUrl
}
// 上傳卡片附件:路徑用 <board_id>/<card_id>/<檔名>,對應 Storage RLS
async function uploadAttachment(boardId: string, cardId: string, file: File) {
const supabase = createClient()
const path = `${boardId}/${cardId}/${file.name}`
const { error } = await supabase.storage
.from('card-attachments')
.upload(path, file, { upsert: true })
if (error) throw error
// 私有 bucket:把路徑存進 card,顯示時再用簽署 URL(有效 1 小時)
await supabase.from('cards').update({ attachment_path: path }).eq('id', cardId)
const { data } = await supabase.storage
.from('card-attachments')
.createSignedUrl(path, 3600)
return data?.signedUrl
}
私有 bucket 顯示圖片一定要用 createSignedUrl(而非 getPublicUrl)——這樣圖片連結會過期,且同樣受 Storage RLS 約束,非成員即使拿到路徑也簽不出 URL。
第六段:Realtime 即時同步(多人看板)
最後一塊,也是讓「協作」名副其實的關鍵:當 A 拖動卡片,B 的畫面要即時更新。用 Postgres Changes 訂閱 cards 表的變更。
第一步(常被忘記):把 cards、lists 加進 realtime publication,Postgres 才會廣播它們的 DML 事件。
-- 讓 Realtime 監聽這兩張表(漏了這步就永遠收不到事件)
alter publication supabase_realtime add table public.cards;
alter publication supabase_realtime add table public.lists;
前端在 Client Component 用 useEffect 訂閱,並在元件卸載時清理:
// components/BoardView.tsx —— 訂閱 cards 變更,即時更新畫面
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function BoardView({ boardId, initialLists }: BoardViewProps) {
const [lists, setLists] = useState(initialLists)
useEffect(() => {
const supabase = createClient()
const channel = supabase
.channel(`board:${boardId}`)
.on(
'postgres_changes',
// 只訂閱「這個看板」的 cards 變更(server 端 filter,省流量)
{ event: '*', schema: 'public', table: 'cards', filter: `board_id=eq.${boardId}` },
(payload) => {
setLists((prev) => applyCardChange(prev, payload)) // 依 eventType 更新本地 state
}
)
.subscribe()
// 元件卸載一定要退訂,否則重複訂閱 + 記憶體洩漏
return () => {
supabase.removeChannel(channel)
}
}, [boardId])
return <Board lists={lists} />
}
// 依 INSERT / UPDATE / DELETE 更新本地卡片狀態
function applyCardChange(lists: ListWithCards[], payload: CardChangePayload) {
const { eventType, new: newCard, old: oldCard } = payload
// INSERT → 加進對應 list;UPDATE → 移動/更新;DELETE → 移除
// ...(依 newCard.list_id / oldCard.id 重組 lists,略)
return lists
}
兩個實戰重點:一是用 filter: board_id=eq.${boardId} 讓 Supabase 在伺服器端就只推送這個看板的事件,不會把整個資料庫的卡片變更全塞給你;二是 Realtime 也受 RLS 約束(2026 已預設啟用 Realtime Authorization)——非成員即使訂閱了 channel,也收不到他沒有 SELECT 權限的資料列,安全性一致到底。
最後一步:生成 TypeScript 型別
整條資料流要型別安全,就靠 CLI 從資料庫 schema 生成 Database 型別(《型別生成》篇),前面每個 createClient<Database> 才有意義。
npx supabase gen types typescript --project-id "$PROJECT_REF" > types/database.types.ts
有了它,supabase.from('cards').insert({ ... }) 的欄位會被 IDE 檢查、拼錯欄位名馬上紅字——整個看板 App 從資料庫到前端全程型別安全。
常見錯誤與最佳實踐
把「單獨學每個元件時不會遇到、組起來才會爆」的整合坑集中列一遍。這些正是全端實戰最有價值的部分。
坑一:在 Server Component 用 getSession() 卻沒有 middleware。
getSession() 只讀本地 cookie、不驗 JWT,token 過期後它照樣回一個「過期的 session」,而 Server Component 又沒地方刷新 token,結果就是資料時好時壞。正確做法:middleware 每次請求呼叫 getUser() 觸發刷新並寫回 cookie;需要驗證身份的地方一律用 getUser()(向伺服器驗 JWT)而非 getSession()。
坑二:RLS 政策沒對上,前端「查不到但也不報錯」。
RLS 被拒絕時,SELECT 通常不是報錯,而是回傳空陣列——很多人以為是「資料還沒建」,其實是「政策沒讓你看到」。以看板為例,忘了寫「建看板自動加 owner 進 board_members」的觸發器,就會發生「建完看板卻讀不到自己的看板」。正確做法:遇到「明明有資料卻查不到」,先在 Studio SQL Editor 用 owner 身份手動查一次確認資料在,再回頭檢查 RLS 政策與 is_board_member 邏輯。
坑三:自我參照 RLS 無窮遞迴。
board_members 的政策若直接查 board_members 自己,會觸發自己的 RLS、再觸發、再觸發……Postgres 直接報 infinite recursion。正確做法:把「判斷成員資格」的查詢包進 security definer 函式(本文的 is_board_member),讓它以定義者權限繞過該表 RLS。
坑四:Realtime 訂閱了卻收不到事件。
三個最常見原因:(1) 忘了 alter publication supabase_realtime add table ...;(2) 訂閱者對該資料沒有 RLS SELECT 權限(這其實是正確的安全行為);(3) channel 沒帶 token 或元件卸載沒 removeChannel。正確做法:按「publication 有加表 → RLS 有 SELECT 權限 → channel 生命週期」的順序排查。
坑五:Storage 路徑設計沒配合 RLS。
把檔案上傳到 avatar.png(沒有 uid 前綴),Storage RLS 的 storage.foldername(name) 就解析不出擁有者,政策形同虛設。正確做法:路徑第一段一定放權限錨點(<uid>/... 或 <board_id>/...),讓 Storage RLS 能用 foldername 解析出來判斷。
坑六:私有 bucket 用 getPublicUrl。
私有 bucket 的 getPublicUrl 會回一個「看起來像網址但打不開」的連結,讓人以為壞了。正確做法:私有檔案一律用 createSignedUrl(帶有效期),它同樣受 Storage RLS 保護。
坑七:前端還在自己做權限判斷。
既然 RLS 已經把關,前端再寫 if (card.owner === user.id) 之類的過濾是多餘且危險的(給人「前端擋得住」的錯覺)。正確做法:信任 RLS,前端只負責 UI 與即時回饋——這是《最佳實踐與反模式》「別把商業邏輯全塞前端」的直接應用。
小結
這是 Supabase 系列教學的第 047 篇,也是全系列的整合實戰。上一篇《最佳實踐與反模式》把橫跨資料庫、RLS、Auth、Storage、Realtime 的關鍵原則收斂成一份「該做 vs 別做」清單;這一篇,我們把那份清單和前面所有元件拿到一個真實專案裡兌現——用 Supabase + Next.js 從零打造一個多人協作看板,逐段 build 出 schema、RLS、@supabase/ssr 登入、受 RLS 保護的 CRUD、Storage 上傳、Realtime 同步,最後用 gen types 讓整條資料流型別安全。
一句話濃縮這篇的心法:全端 App 的難處不在單一元件,而在元件之間的接縫;而 Supabase 把這些接縫都收斂到同一個守門員——資料庫的 RLS 上。 只要 schema 設計對、RLS 政策立好,Auth、CRUD、Storage、Realtime 四條路徑就自動共享同一套安全邊界,你不必在每一層重複寫權限判斷。
- 架構心法:PostgreSQL 的 RLS 是唯一安全邊界,REST/Storage/Realtime 三條路徑都通過它
- SSR 關鍵:用
@supabase/ssr建瀏覽器/伺服器/middleware 三個 client,middleware 每次請求刷新 token - RLS 落地:用
board_members中介表 +security definer函式表達「誰能存取哪個看板」 - Storage 心法:路徑第一段放權限錨點,私有檔案用簽署 URL
- Realtime 心法:記得加 publication、用 server 端 filter、退訂清理、且它同樣受 RLS 約束
到這裡,散落全系列的積木已經拼成一台能跑的車。下一篇《Supabase vs Firebase》,我們會站得更高,把 Supabase 和它最常被拿來比較的對手放在一起,幫你在真實選型時做出有依據的決定——什麼情境選 Supabase、什麼情境選 Firebase。把零件組成產品是實戰的驗收,而知道何時該用哪套工具,則是資深工程師的判斷力。