Supabase + Next.js 全端實戰:從零打造協作看板 | Supabase 完整教學

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 刷新 tokenAuth 驗證系統 篇
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。把零件組成產品是實戰的驗收,而知道何時該用哪套工具,則是資深工程師的判斷力。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →