測驗:用 Supabase 打造完整 CRUD 應用
共 5 題,點選答案後會立即顯示結果
1. 在 Supabase 的純前端架構中,為什麼 anon key 放在前端程式碼裡是安全的?
2. 以下是 Supabase Client 的 CRUD 函式回傳結果,標準的處理模式是什麼?
3. 在 useMutation 的 onSuccess 中呼叫 invalidateQueries({ queryKey: ['todos', 'list'] }) 的目的是什麼?
4. 純前端 SPA 部署到 Vercel 或 Netlify 時,為什麼需要設定「所有路由都指向 index.html」的 rewrite 規則?
5. 在 Optimistic Update 中,onMutate 裡呼叫 cancelQueries 的目的是什麼?
**系列**:React 純前端實戰(第 5 篇,共 5 篇——完結篇)
**難度**:L1-起步
**前置知識**:讀完本系列第 1-4 篇、對 Supabase 有基本認識(知道它是什麼就好)
一句話說明
把前四篇學到的 Router + Query 全部串起來,搭配 Supabase 作為後端,完成一個可部署的純前端 CRUD 應用。
這篇在講什麼
前四篇我們分別看懂了 TanStack Router(路由)和 TanStack Query(資料管理),也看了它們怎麼整合。但那些範例都用假的 fetch('/api/...')——實際專案中,你的資料要存在哪裡?
這篇用一個「待辦清單」應用,把所有概念串在一起,並用 Supabase 作為真正的後端。你會看到:
- 一個完整專案的檔案結構長什麼樣
- Supabase 怎麼取代傳統的後端 API
- 前面學的 loader、queryOptions、useMutation 怎麼在真實專案中組合
- 為什麼純前端也可以安全地操作資料庫
Supabase 是什麼?30 秒搞懂
傳統架構 Supabase 架構(純前端)
┌──────────┐ ┌──────────┐
│ React │ │ React │
│ 前端 │ │ 前端 │
└────┬─────┘ └────┬─────┘
│ fetch('/api/todos') │ supabase.from('todos').select()
↓ ↓
┌──────────┐ ┌──────────┐
│ Express │ │ Supabase │ ← 雲端服務
│ 後端 API │ │ (自帶 DB │
└────┬─────┘ │ + Auth │
│ SQL query │ + API) │
↓ └──────────┘
┌──────────┐
│ PostgreSQL│
│ 資料庫 │
└──────────┘
Code language: JavaScript (javascript)翻譯:Supabase 是一個「後端即服務」(BaaS),它幫你把資料庫、身份驗證、API 全部打包好。你的 React 前端可以直接呼叫 Supabase,不需要自己寫後端。
專案結構總覽
AI 幫你生成一個 React + TanStack + Supabase 的待辦清單應用,檔案結構大概長這樣:
todo-app/
├── .env ← Supabase 連線資訊(環境變數)
├── package.json
├── vite.config.ts
├── src/
│ ├── main.tsx ← 應用入口(Router + Query 初始化)
│ ├── lib/
│ │ └── supabase.ts ← Supabase Client 初始化
│ ├── types/
│ │ └── database.types.ts ← 資料庫型別定義(自動生成的)
│ ├── api/
│ │ └── todos.ts ← CRUD 函式 + Query Options
│ ├── routes/
│ │ ├── __root.tsx ← 根路由(Layout + Context)
│ │ ├── index.tsx ← 首頁 /
│ │ ├── login.tsx ← 登入頁 /login
│ │ └── todos/
│ │ ├── index.tsx ← 待辦列表 /todos
│ │ └── $todoId.tsx ← 待辦詳情 /todos/123
│ └── components/
│ ├── TodoList.tsx
│ ├── TodoItem.tsx
│ └── CreateTodoForm.tsx
└── supabase/
└── migrations/ ← 資料庫 Schema(SQL 檔案)
Code language: PHP (php)翻譯對照:
| 資料夾/檔案 | 角色 | 對應前幾篇學的 |
|---|---|---|
lib/supabase.ts |
Supabase 連線設定 | 本篇新概念 |
types/database.types.ts |
資料庫的 TypeScript 型別 | 本篇新概念 |
api/todos.ts |
CRUD 函式 + queryOptions | 第 3、4 篇 |
routes/ |
頁面路由 | 第 2、4 篇 |
routes/__root.tsx |
根路由 + context | 第 4 篇 |
關鍵觀察:整個專案沒有
server/或backend/資料夾。所有跟後端的溝通都透過lib/supabase.ts裡的 Supabase Client 完成。
Supabase 設定:初始化 Client
環境變數
# .env
VITE_SUPABASE_URL=https://abcdefg.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Code language: PHP (php)翻譯:
VITE_SUPABASE_URL:你的 Supabase 專案網址(每個專案不同)VITE_SUPABASE_ANON_KEY:公開的 API 金鑰(等等會解釋為什麼公開也安全)VITE_前綴:Vite 的規則——只有VITE_開頭的環境變數才會被打包進前端程式碼
初始化 Supabase Client
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
import type { Database } from '../types/database.types'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey)
Code language: JavaScript (javascript)逐行翻譯:
| 程式碼 | 翻譯 |
|---|---|
createClient |
建立一個 Supabase 連線物件 |
<Database> |
告訴 TypeScript 資料庫的結構長什麼樣(型別安全) |
import.meta.env.VITE_SUPABASE_URL |
從環境變數讀取 Supabase 網址 |
export const supabase |
匯出這個連線物件,整個專案都用這一個 |
這段程式很短,但它是整個專案跟 Supabase 溝通的基礎。整個專案只需要這一個 supabase 物件。
資料庫 Schema 與 RLS
資料表設計
-- supabase/migrations/001_create_todos.sql
create table todos (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) not null,
title text not null,
completed boolean default false,
created_at timestamptz default now()
);
Code language: JavaScript (javascript)翻譯:建立一張 todos 表,每筆待辦有 id、擁有者(user_id)、標題、完成狀態、建立時間。
RLS:為什麼 anon key 放在前端是安全的
你可能會想:「把 API key 放在前端程式碼裡,任何人都看得到,這不危險嗎?」
答案是:Supabase 的安全模型不靠隱藏 key,而是靠 RLS(Row Level Security)。
-- 啟用 RLS
alter table todos enable row level security;
-- 政策:使用者只能看自己的待辦
create policy "Users can view own todos"
on todos for select
using (auth.uid() = user_id);
-- 政策:使用者只能新增自己的待辦
create policy "Users can insert own todos"
on todos for insert
with check (auth.uid() = user_id);
-- 政策:使用者只能修改自己的待辦
create policy "Users can update own todos"
on todos for update
using (auth.uid() = user_id);
-- 政策:使用者只能刪除自己的待辦
create policy "Users can delete own todos"
on todos for delete
using (auth.uid() = user_id);
Code language: JavaScript (javascript)翻譯:
沒有 RLS 的情況:
任何人拿到 anon key → 可以讀/寫所有人的資料 → 危險!
有 RLS 的情況:
任何人拿到 anon key → 可以連線
但每次操作資料庫時 → PostgreSQL 會檢查:
「這個請求的使用者是誰?」(auth.uid())
「他有權限碰這筆資料嗎?」(user_id 要匹配)
→ 只能碰自己的資料 → 安全!
**關鍵理解**:anon key 只是讓你「能連上 Supabase」。真正的安全防線是資料庫層的 RLS 政策。就像大樓的大門對所有住戶開放,但每間房間有各自的鎖。
TypeScript 型別生成
Supabase 可以根據你的資料庫 Schema 自動生成 TypeScript 型別:
npx supabase gen types typescript --project-id abcdefg > src/types/database.types.ts
生成的檔案長這樣(節錄):
// src/types/database.types.ts(自動生成,不要手動修改)
export interface Database {
public: {
Tables: {
todos: {
Row: { // 讀取時的型別
id: string
user_id: string
title: string
completed: boolean
created_at: string
}
Insert: { // 新增時的型別
id?: string // 有預設值的欄位變成可選
user_id: string
title: string
completed?: boolean
created_at?: string
}
Update: { // 更新時的型別
id?: string // 所有欄位都是可選的
user_id?: string
title?: string
completed?: boolean
created_at?: string
}
}
}
}
}
Code language: PHP (php)翻譯:
| 型別 | 意思 | 什麼時候用 |
|---|---|---|
Row |
從資料庫讀出來的完整資料 | select 查詢的回傳值 |
Insert |
新增時要給的欄位(有預設值的可以不給) | insert 操作的參數 |
Update |
更新時要改的欄位(只給要改的就好) | update 操作的參數 |
有了這些型別,當你寫 supabase.from('todos').select('*') 時,TypeScript 會自動知道回傳的資料有哪些欄位、是什麼型別。打錯欄位名稱編輯器會立刻報錯。
API 層封裝:CRUD 函式 + Query Options
這是整個專案的核心檔案——把 Supabase 的 CRUD 操作和 TanStack Query 的設定全部集中在一起。
CRUD 函式
// src/api/todos.ts
import { supabase } from '../lib/supabase'
import type { Database } from '../types/database.types'
// 從自動生成的型別中取出需要的型別
type Todo = Database['public']['Tables']['todos']['Row']
type TodoInsert = Database['public']['Tables']['todos']['Insert']
type TodoUpdate = Database['public']['Tables']['todos']['Update']
// ===== 查詢(Read)=====
export async function getTodos(status?: 'completed' | 'active') {
let query = supabase
.from('todos') // 從 todos 表
.select('*') // 選所有欄位
.order('created_at', { ascending: false }) // 新的在前
// 如果有篩選條件
if (status === 'completed') {
query = query.eq('completed', true)
} else if (status === 'active') {
query = query.eq('completed', false)
}
const { data, error } = await query
if (error) throw error // 有錯就拋出,讓 TanStack Query 接住
return data // 回傳 Todo[]
}
// ===== 查詢單筆(Read)=====
export async function getTodoById(id: string) {
const { data, error } = await supabase
.from('todos')
.select('*')
.eq('id', id) // WHERE id = ?
.single() // 只要一筆(回傳物件而非陣列)
if (error) throw error
return data // 回傳 Todo
}
// ===== 新增(Create)=====
export async function createTodo(todo: TodoInsert) {
const { data, error } = await supabase
.from('todos')
.insert(todo) // 插入一筆
.select() // 回傳插入後的完整資料
.single()
if (error) throw error
return data
}
// ===== 修改(Update)=====
export async function updateTodo(id: string, updates: TodoUpdate) {
const { data, error } = await supabase
.from('todos')
.update(updates) // 更新指定欄位
.eq('id', id) // WHERE id = ?
.select()
.single()
if (error) throw error
return data
}
// ===== 刪除(Delete)=====
export async function deleteTodo(id: string) {
const { error } = await supabase
.from('todos')
.delete() // 刪除
.eq('id', id) // WHERE id = ?
if (error) throw error
}
Code language: JavaScript (javascript)核心概念翻譯:
| Supabase 寫法 | 對應的 SQL | 翻譯 |
|---|---|---|
.from('todos') |
FROM todos |
指定操作哪張表 |
.select('*') |
SELECT * |
取所有欄位 |
.eq('id', id) |
WHERE id = ? |
篩選條件:id 等於某個值 |
.order('created_at', { ascending: false }) |
ORDER BY created_at DESC |
按建立時間倒序排列 |
.single() |
LIMIT 1 |
只回傳一筆(回傳物件而非陣列) |
.insert(todo) |
INSERT INTO todos ... |
插入一筆資料 |
.update(updates) |
UPDATE todos SET ... |
更新指定欄位 |
.delete() |
DELETE FROM todos |
刪除資料 |
**觀察重點**:每個函式的結構都一樣——呼叫 Supabase、解構出
{ data, error }、有錯就拋出、沒錯就回傳 data。這是 Supabase Client 的標準模式。
Query Options 定義
// src/api/todos.ts(接續上面)
import { queryOptions } from '@tanstack/react-query'
// 列表的 query options
export const todosQueryOptions = (status?: 'completed' | 'active') =>
queryOptions({
queryKey: ['todos', 'list', { status }], // status 不同 → 快取不同
queryFn: () => getTodos(status),
})
// 單筆的 query options
export const todoDetailQueryOptions = (todoId: string) =>
queryOptions({
queryKey: ['todos', 'detail', todoId],
queryFn: () => getTodoById(todoId),
})
Code language: JavaScript (javascript)翻譯:這跟第 4 篇學的完全一樣——把 queryKey 和 queryFn 打包在一起,讓 loader 和元件共用。唯一的差別是 queryFn 裡面不再是 fetch('/api/...'),而是我們上面寫的 Supabase CRUD 函式。
TanStack Query 整合:Mutation
第 3 篇學了 useQuery(讀取資料),現在要用 useMutation(修改資料)。
基本的 Mutation:新增待辦
// src/components/CreateTodoForm.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createTodo } from '../api/todos'
function CreateTodoForm() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: createTodo, // 要執行的函式
onSuccess: () => {
queryClient.invalidateQueries({ // 成功後,把列表快取標記為「過期」
queryKey: ['todos', 'list'],
})
},
})
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
mutation.mutate({ // 觸發 mutation
title: formData.get('title') as string,
user_id: '...', // 從認證狀態取得
})
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="新增待辦..." />
<button
type="submit"
disabled={mutation.isPending} // 送出中時停用按鈕
>
{mutation.isPending ? '新增中...' : '新增'}
</button>
{mutation.isError && (
<p>新增失敗:{mutation.error.message}</p>
)}
</form>
)
}
Code language: JavaScript (javascript)逐行翻譯:
| 程式碼 | 翻譯 |
|---|---|
useMutation({ mutationFn }) |
建立一個「修改資料」的操作(對應 useQuery 的「讀取資料」) |
mutationFn: createTodo |
當觸發 mutation 時,執行 createTodo 函式 |
onSuccess |
操作成功後要做什麼 |
invalidateQueries({ queryKey: ['todos', 'list'] }) |
把所有以 ['todos', 'list'] 開頭的快取標記為過期,自動重新抓取 |
mutation.mutate({ ... }) |
觸發 mutation,傳入參數 |
mutation.isPending |
mutation 正在執行中嗎? |
mutation.isError |
mutation 失敗了嗎? |
Mutation 的流程
使用者點「新增」按鈕
↓
mutation.mutate({ title: '買牛奶' })
↓
呼叫 createTodo → Supabase 插入資料
↓ 成功
onSuccess 執行
↓
invalidateQueries(['todos', 'list'])
↓
TanStack Query 自動重新抓取列表 → 畫面更新!
Code language: CSS (css)刪除 + 完成切換
// src/components/TodoItem.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateTodo, deleteTodo } from '../api/todos'
function TodoItem({ todo }: { todo: Todo }) {
const queryClient = useQueryClient()
// 切換完成狀態
const toggleMutation = useMutation({
mutationFn: () => updateTodo(todo.id, { completed: !todo.completed }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
// 刪除
const deleteMutation = useMutation({
mutationFn: () => deleteTodo(todo.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleMutation.mutate()}
disabled={toggleMutation.isPending}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}>
{todo.title}
</span>
<button
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
>
刪除
</button>
</li>
)
}
Code language: JavaScript (javascript)翻譯:每個待辦項目有兩個 mutation——切換完成狀態和刪除。兩者的模式一模一樣:mutationFn 做事,onSuccess 裡面 invalidate 讓列表重新抓取。
Optimistic Updates:即時回饋
上面的做法有一個小問題:使用者勾選「完成」後,要等 Supabase 回應、再等列表重新抓取,才會看到畫面更新。如果網路慢,會有明顯的延遲感。
Optimistic Update(樂觀更新)的想法是:「先假設成功,立刻更新畫面。如果失敗再退回去。」
const toggleMutation = useMutation({
mutationFn: () => updateTodo(todo.id, { completed: !todo.completed }),
// 在 API 回應之前就先更新畫面
onMutate: async () => {
// 1. 取消正在進行的查詢(避免覆蓋我們的樂觀更新)
await queryClient.cancelQueries({ queryKey: ['todos', 'list'] })
// 2. 保存目前的快取(萬一失敗要回復)
const previousTodos = queryClient.getQueryData(['todos', 'list'])
// 3. 樂觀更新快取
queryClient.setQueryData(
['todos', 'list'],
(old: Todo[] | undefined) =>
old?.map(t =>
t.id === todo.id
? { ...t, completed: !t.completed } // 先把這筆改掉
: t
)
)
return { previousTodos } // 回傳舊資料(給 onError 用)
},
// 失敗時回復到之前的狀態
onError: (_err, _vars, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos', 'list'], context.previousTodos)
}
},
// 不管成功失敗,最後都重新抓一次確保資料正確
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos', 'list'] })
},
})
Code language: JavaScript (javascript)逐行翻譯:
| 程式碼 | 翻譯 |
|---|---|
onMutate |
在 API 請求送出的同時執行(不等回應) |
cancelQueries |
如果正在重新抓列表,先取消(不然會把我們的樂觀更新蓋掉) |
getQueryData |
從快取拿出目前的資料(備份) |
setQueryData |
直接修改快取裡的資料(不經過 API) |
return { previousTodos } |
把備份傳給 onError |
onError |
API 失敗時,用備份把快取還原 |
onSettled |
不管成功或失敗,最後都 invalidate 一次,確保快取跟資料庫同步 |
Optimistic Update 的流程
使用者勾選「完成」
↓
onMutate 執行(API 還沒回應)
→ 備份舊快取
→ 直接改快取(畫面立刻更新!)
↓
同時,API 請求送出...
↓
API 成功 → onSettled → invalidate → 重新抓取確認
API 失敗 → onError → 用備份還原快取(畫面回復)
→ onSettled → invalidate → 重新抓取
**什麼時候該用 Optimistic Update?** 適合「使用者預期立刻看到反應」的操作,像是勾選完成、刪除。不適合「結果不確定」的操作,像是提交表單(可能會驗證失敗)。
TanStack Router 整合:路由結構
根路由:設定 Context
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import { supabase } from '../lib/supabase'
import type { Session } from '@supabase/supabase-js'
interface RouterContext {
queryClient: QueryClient
session: Session | null // 認證狀態也放進 context
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => (
<div>
<nav>{/* 導覽列 */}</nav>
<Outlet />
</div>
),
})
Code language: JavaScript (javascript)翻譯:跟第 4 篇一樣,把 queryClient 放進 Router 的 context。這裡額外加了 session(使用者登入狀態),讓所有路由都能知道「現在有沒有人登入」。
待辦列表頁:loader + search params
// src/routes/todos/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { todosQueryOptions } from '../../api/todos'
// search params 驗證
const todosSearchSchema = z.object({
status: z.enum(['all', 'completed', 'active']).optional(),
})
export const Route = createFileRoute('/todos/')({
validateSearch: todosSearchSchema,
loader: async ({ search, context }) => {
// 用 search.status 決定要抓什麼資料
await context.queryClient.ensureQueryData(
todosQueryOptions(
search.status === 'all' ? undefined : search.status
)
)
},
component: TodoListPage,
})
function TodoListPage() {
const { status } = Route.useSearch()
const { data: todos } = useQuery(
todosQueryOptions(status === 'all' ? undefined : status)
)
return (
<div>
<h1>我的待辦</h1>
<FilterTabs currentStatus={status} />
<CreateTodoForm />
<ul>
{todos?.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</div>
)
}
Code language: JavaScript (javascript)翻譯:這就是第 4 篇學的「Search Params 驅動查詢」在實際專案中的應用。URL /todos?status=completed 會只顯示已完成的待辦。
待辦詳情頁
// src/routes/todos/$todoId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { todoDetailQueryOptions } from '../../api/todos'
export const Route = createFileRoute('/todos/$todoId')({
loader: async ({ params, context }) => {
await context.queryClient.ensureQueryData(
todoDetailQueryOptions(params.todoId)
)
},
pendingComponent: () => <div>載入中...</div>,
errorComponent: ({ error }) => <div>找不到這筆待辦:{error.message}</div>,
component: TodoDetailPage,
})
function TodoDetailPage() {
const { todoId } = Route.useParams()
const { data: todo } = useQuery(todoDetailQueryOptions(todoId))
return (
<article>
<h1>{todo?.title}</h1>
<p>狀態:{todo?.completed ? '已完成' : '未完成'}</p>
<p>建立時間:{todo?.created_at}</p>
</article>
)
}
Code language: JavaScript (javascript)翻譯:這跟第 4 篇的 postDetailQueryOptions 範例幾乎一模一樣,只是把 fetch 換成了 Supabase 的 getTodoById。
認證流程簡介
純前端應用中,Supabase Auth 負責使用者登入/登出。
登入
// src/routes/login.tsx
import { supabase } from '../lib/supabase'
function LoginPage() {
const handleEmailLogin = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) alert(error.message)
}
const handleGoogleLogin = async () => {
await supabase.auth.signInWithOAuth({
provider: 'google',
})
}
return (
<div>
{/* 帳號密碼登入表單 */}
{/* Google 登入按鈕 */}
</div>
)
}
Code language: JavaScript (javascript)翻譯:
| 程式碼 | 翻譯 |
|---|---|
signInWithPassword |
用帳號密碼登入 |
signInWithOAuth({ provider: 'google' }) |
跳轉到 Google 登入頁面 |
認證狀態管理:Session Listener
// src/main.tsx
import { useState, useEffect } from 'react'
import { supabase } from './lib/supabase'
import type { Session } from '@supabase/supabase-js'
function App() {
const [session, setSession] = useState<Session | null>(null)
useEffect(() => {
// 取得目前的 session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session)
})
// 監聽 session 變化(登入、登出、token 更新)
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session)
}
)
// 元件卸載時取消監聽
return () => subscription.unsubscribe()
}, [])
// 把 session 傳進 Router context
const router = createRouter({
routeTree,
context: { queryClient, session },
})
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
)
}
Code language: JavaScript (javascript)翻譯:
| 程式碼 | 翻譯 |
|---|---|
getSession() |
程式啟動時,檢查有沒有已登入的 session |
onAuthStateChange |
持續監聽——當使用者登入、登出、token 自動更新時,通知我 |
subscription.unsubscribe() |
元件卸載時停止監聽(避免記憶體洩漏) |
context: { queryClient, session } |
把 session 放進 Router 的 context,所有路由都能存取 |
Route Guard:保護需要登入的頁面
// src/routes/todos/index.tsx(加上 beforeLoad)
export const Route = createFileRoute('/todos/')({
beforeLoad: ({ context }) => {
if (!context.session) {
throw redirect({ to: '/login' }) // 沒登入就跳轉到登入頁
}
},
// ... loader, component 等
})
Code language: JavaScript (javascript)翻譯:beforeLoad 在 loader 之前執行。如果 context 裡面沒有 session(代表沒登入),就 redirect 到登入頁。這就是純前端的「路由守衛」。
認證流程全貌
使用者打開 /todos
↓
Router 匹配到 /todos 路由
↓
beforeLoad 執行
→ 檢查 context.session
→ 有 session → 繼續
→ 沒 session → redirect('/login')
↓
loader 執行
→ ensureQueryData(todosQueryOptions)
→ Supabase Client 發出請求時自動帶上 session 的 token
→ RLS 根據 token 裡的 user_id 篩選資料
↓
component 渲染 → 只看到自己的待辦
Code language: JavaScript (javascript)部署:Vite Build + 靜態託管
整個應用是純前端——沒有 Node.js 伺服器,只有 HTML + JS + CSS。部署非常簡單。
Build
npm run build
Vite 會在 dist/ 資料夾產出靜態檔案:
dist/
├── index.html ← 單一 HTML 入口
├── assets/
│ ├── index-abc123.js ← 打包後的 JavaScript
│ └── index-def456.css ← 打包後的 CSS
部署到靜態託管
| 平台 | 部署方式 | 重點設定 |
|---|---|---|
| Vercel | 連接 GitHub repo,自動部署 | Framework Preset 選 Vite |
| Netlify | 連接 GitHub repo,自動部署 | Build command: npm run build,Publish directory: dist |
| Cloudflare Pages | 連接 GitHub repo,自動部署 | 同上 |
SPA 路由的重點設定
因為是 SPA,所有路由都靠前端 JavaScript 處理。如果使用者直接訪問 /todos/123,伺服器會找不到這個檔案而回傳 404。
解法:設定「所有路由都指向 index.html」。
# Netlify:在 dist/ 或專案根目錄放一個 _redirects 檔案
/* /index.html 200
Code language: PHP (php)// Vercel:vercel.json
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
Code language: JSON / JSON with Comments (json)翻譯:告訴靜態伺服器——「不管使用者訪問什麼路徑,都回傳 index.html。路由交給前端 JavaScript 處理。」
環境變數設定
在託管平台的設定頁面,加入環境變數:
VITE_SUPABASE_URL=https://abcdefg.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...
Code language: JavaScript (javascript)這些值會在
npm run build時被注入到 JavaScript 中。部署後不能透過改環境變數來切換——要改就要重新 build。
全部串起來:資料流全貌
以下是使用者「新增一筆待辦」的完整資料流,把本系列五篇的概念全部串在一起:
使用者在 /todos 頁面,點了「新增」按鈕
│
│ ① React 元件(第 1 篇)
↓
CreateTodoForm 的 handleSubmit 執行
→ mutation.mutate({ title: '買牛奶', user_id: '...' })
│
│ ② TanStack Query Mutation(第 3 篇 + 本篇)
↓
mutationFn 執行 → createTodo()
→ supabase.from('todos').insert(...)
│
│ ③ Supabase Client(本篇)
↓
請求送到 Supabase(帶著 session token)
→ RLS 檢查:auth.uid() = user_id? → 通過
→ PostgreSQL 插入資料
→ 回傳新增的資料
│
│ ④ Mutation 成功(第 3 篇 + 本篇)
↓
onSuccess → invalidateQueries(['todos', 'list'])
│
│ ⑤ TanStack Query 自動重新抓取(第 3 篇)
↓
getTodos() 重新執行
→ supabase.from('todos').select('*')
→ 回傳更新後的列表
│
│ ⑥ React 自動重新渲染(第 1 篇)
↓
TodoList 元件更新 → 畫面顯示新的待辦!
Code language: JavaScript (javascript)核心概念翻譯
| 你會看到 | 意思 |
|---|---|
createClient<Database>(url, key) |
建立 Supabase 連線,帶型別定義 |
supabase.from('todos').select('*') |
從 todos 表查所有欄位 |
.eq('id', id) |
WHERE 條件 |
.single() |
只要一筆結果(回傳物件,不是陣列) |
{ data, error } = await ... |
Supabase 標準回傳格式:資料和錯誤分開 |
useMutation({ mutationFn, onSuccess }) |
修改資料的操作,成功後做某事 |
invalidateQueries({ queryKey }) |
讓特定快取過期,觸發重新抓取 |
onMutate → setQueryData |
Optimistic Update:不等 API,先改快取 |
supabase.auth.signInWithPassword |
帳號密碼登入 |
supabase.auth.onAuthStateChange |
監聽登入/登出狀態變化 |
beforeLoad + redirect |
路由守衛:沒登入就跳轉 |
RLS policy + auth.uid() |
資料庫層的權限控制:只能碰自己的資料 |
Vibe Coder 檢查點
看到 AI 生成了一個 React + Supabase 的完整專案時,確認這些事:
- [ ] 有
.env檔案嗎? Supabase URL 和 anon key 不應該硬寫在程式碼裡 - [ ] 有啟用 RLS 嗎? 如果 Supabase 的 table 沒有啟用 RLS,任何人都能讀寫所有資料
- [ ] CRUD 函式有處理 error 嗎? Supabase 回傳的
{ data, error }中,error 要檢查 - [ ] Mutation 的 onSuccess 有 invalidate 快取嗎? 不然新增/修改後列表不會更新
- [ ] queryOptions 有在 loader 和元件之間共用嗎? 確保 queryKey 一致(第 4 篇的重點)
- [ ] 需要登入的頁面有 beforeLoad 檢查嗎? 沒有的話未登入使用者能直接訪問
- [ ] 部署設定有處理 SPA 路由嗎? 需要「所有路徑都指向 index.html」的 rewrite 規則
- [ ] TypeScript 型別是自動生成的嗎?
database.types.ts應該用supabase gen types產出,不要手動寫
必看懂 vs 知道就好
必看懂(這套技術棧的核心)
| 概念 | 為什麼重要 |
|---|---|
| Supabase Client 初始化 | 整個專案只做一次,但要知道它在哪、怎麼運作 |
.from().select().eq() 查詢鏈 |
每個 CRUD 函式都長這樣 |
{ data, error } 解構 |
Supabase 的標準回傳模式 |
useMutation + invalidateQueries |
修改資料後更新畫面的標準流程 |
| RLS 的基本概念 | 理解為什麼純前端也能安全 |
beforeLoad 做認證檢查 |
保護路由的標準做法 |
知道就好(遇到再查)
- Optimistic Updates:讓操作感覺更快,但程式碼較複雜。先用 invalidate 就好,體驗不好再加
- Supabase Realtime:即時訂閱資料變化(像聊天室),一般 CRUD 應用不需要
- Supabase Edge Functions:需要後端邏輯時才用(例如寄信、串接第三方 API)
- Supabase Storage:檔案上傳功能
- RLS 進階政策:例如「管理員可以看所有人的資料」這種複雜規則
本系列回顧
恭喜你讀完整個系列!讓我們回顧一下每篇學了什麼:
| 篇數 | 主題 | 你學會看懂什麼 |
|---|---|---|
| #01 | 架構總覽 | SPA vs SSR 的差異,為什麼選 React + TanStack |
| #02 | TanStack Router | 檔案路由、動態參數、search params、型別安全 |
| #03 | TanStack Query | useQuery、queryKey、快取機制、useMutation 基礎 |
| #04 | Router + Query 整合 | loader、ensureQueryData、Pending UI、Error 處理 |
| #05 | Supabase 實戰 | Supabase CRUD、RLS、認證、Optimistic Updates、部署 |
這套技術棧的完整架構
┌─────────────────────────────────────────────┐
│ 使用者瀏覽器 │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ TanStack │ │ TanStack Query │ │
│ │ Router │ │ │ │
│ │ │ │ useQuery 快取管理 │ │
│ │ 路由匹配 │───→│ useMutation │ │
│ │ loader 預取 │ │ invalidate │ │
│ │ beforeLoad │ │ optimistic update│ │
│ └──────────────┘ └────────┬─────────┘ │
│ │ │
│ Supabase Client │
│ │ │
└───────────────────────────────┼──────────────┘
│
↓
┌──────────────────┐
│ Supabase │
│ │
│ PostgreSQL + RLS│
│ Auth │
│ Storage │
└──────────────────┘
當你看到 AI 幫你生成了一個 React 專案,裡面有 @tanstack/react-router、@tanstack/react-query、@supabase/supabase-js 這三個套件時——你現在知道每一層在做什麼了。
進階測驗:用 Supabase 打造完整 CRUD 應用
共 5 題,包含情境題與錯誤診斷題。