【React 純前端實戰】#05 實戰:用 Supabase 打造完整 CRUD 應用

測驗:用 Supabase 打造完整 CRUD 應用

共 5 題,點選答案後會立即顯示結果

1. 在 Supabase 的純前端架構中,為什麼 anon key 放在前端程式碼裡是安全的?

  • A. 因為 anon key 經過加密,無法被反編譯取得
  • B. 因為 Vite 會在打包時自動隱藏環境變數的值
  • C. 因為真正的安全防線是資料庫層的 RLS 政策,anon key 只是讓你能連上 Supabase
  • D. 因為 anon key 只有讀取權限,無法做任何寫入操作

2. 以下是 Supabase Client 的 CRUD 函式回傳結果,標準的處理模式是什麼?

const { data, error } = await supabase .from(‘todos’) .select(‘*’)
  • A. 直接回傳 data,不需要檢查 error
  • B. 如果 error 存在就拋出,沒有 error 就回傳 data
  • C. 用 try-catch 包住整個查詢來處理錯誤
  • D. 把 data 和 error 一起回傳,讓呼叫端自行判斷

3. 在 useMutation 的 onSuccess 中呼叫 invalidateQueries({ queryKey: ['todos', 'list'] }) 的目的是什麼?

  • A. 清除所有快取,強制使用者重新登入
  • B. 把新增的資料直接寫入快取,避免額外的 API 請求
  • C. 取消正在進行的 API 請求,防止資料衝突
  • D. 將對應的快取標記為過期,觸發 TanStack Query 自動重新抓取列表

4. 純前端 SPA 部署到 Vercel 或 Netlify 時,為什麼需要設定「所有路由都指向 index.html」的 rewrite 規則?

  • A. 因為 Supabase 的 API 請求需要透過 index.html 轉發
  • B. 因為 SPA 的路由由前端 JavaScript 處理,伺服器直接訪問子路徑會找不到對應的檔案而回傳 404
  • C. 因為 TanStack Router 的 loader 必須從 index.html 開始載入才能運作
  • D. 因為環境變數只有在 index.html 中才能被讀取

5. 在 Optimistic Update 中,onMutate 裡呼叫 cancelQueries 的目的是什麼?

onMutate: async () => { await queryClient.cancelQueries({ queryKey: [‘todos’, ‘list’] }) const previousTodos = queryClient.getQueryData([‘todos’, ‘list’]) queryClient.setQueryData([‘todos’, ‘list’], (old) => /* 樂觀更新 */) return { previousTodos } }
  • A. 取消 mutation 的 API 請求,改用本地資料
  • B. 停止所有跟 todos 相關的 mutation 操作
  • C. 取消正在進行的查詢請求,避免其回傳結果覆蓋掉我們即將做的樂觀更新
  • D. 清除快取中的舊資料,為樂觀更新騰出空間

**系列**: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 篇學的完全一樣——把 queryKeyqueryFn 打包在一起,讓 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: '買牛奶' })
  ↓
呼叫 createTodoSupabase 插入資料
  ↓ 成功
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 }) 讓特定快取過期,觸發重新抓取
onMutatesetQueryData 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 題,包含情境題與錯誤診斷題。

1. 你用 AI 生成了一個 React + Supabase 的待辦清單應用。新增待辦後,畫面上的列表沒有自動更新,必須手動重新整理頁面才會看到新資料。你會先檢查哪裡? 情境題

  • A. 檢查 Supabase 的 RLS 政策是否正確設定
  • B. 檢查 useMutationonSuccess 有沒有呼叫 invalidateQueries
  • C. 檢查 createClient 有沒有帶入 <Database> 型別參數
  • D. 檢查 Supabase 的 Realtime 功能有沒有啟用

2. 你正在開發一個待辦清單的「勾選完成」功能。使用者反映勾選後要等一兩秒畫面才會更新,體驗不好。在不增加太多程式碼複雜度的前提下,你應該採用什麼策略? 情境題

  • A. 啟用 Supabase Realtime 來訂閱資料變化,即時更新畫面
  • B. 把 TanStack Query 的 staleTime 設為 0,讓快取永遠過期以加速重新抓取
  • C. 使用 Optimistic Update:在 onMutate 中先用 setQueryData 更新快取,失敗時用 onError 回復
  • D. 在元件中用 useState 維護一份本地的待辦列表,勾選時直接修改本地狀態

3. 你把 React + Supabase 的 SPA 部署到 Netlify 後,首頁可以正常顯示,但直接在瀏覽器輸入 /todos/abc123 會出現 404 頁面。最可能的原因和解法是什麼? 情境題

  • A. TanStack Router 沒有正確設定 /todos/$todoId 路由,需要修改路由定義
  • B. Supabase 的 RLS 政策阻擋了對該筆待辦的存取
  • C. Vite 的 build 設定有誤,需要在 vite.config.ts 中加入 base: '/'
  • D. Netlify 缺少 SPA 的 rewrite 規則,需要在專案中加入 _redirects 檔案,內容為 /* /index.html 200

4. 同事寫了以下的 Supabase CRUD 函式,但使用時偶爾會出現「Cannot read properties of null」的錯誤。問題出在哪裡? 錯誤診斷

export async function getTodoById(id: string) { const { data } = await supabase .from(‘todos’) .select(‘*’) .eq(‘id’, id) .single() return data }
  • A. .single() 不能和 .eq() 一起使用,應該移除 .single()
  • B. 沒有解構 error 並檢查錯誤——當查詢失敗時 data 會是 null,直接回傳會導致呼叫端存取 null 的屬性
  • C. 應該用 .maybeSingle() 取代 .single(),因為資料可能不存在
  • D. 缺少 .order() 排序,導致 Supabase 不知道要回傳哪一筆

5. 以下程式碼的 loader 有正常抓資料,但切換到 /todos 頁面時,元件裡的 useQuery 卻重新發出了一次 API 請求,沒有使用 loader 預取的快取。最可能的原因是什麼? 錯誤診斷

// api/todos.ts const todosListOptions = (status?: string) => queryOptions({ queryKey: [‘todos’, { status }], queryFn: () => getTodos(status), }) // routes/todos/index.tsx export const Route = createFileRoute(‘/todos/’)({ validateSearch: todosSearchSchema, loader: async ({ search, context }) => { await context.queryClient.ensureQueryData( queryOptions({ queryKey: [‘todos’, ‘list’, { status: search.status }], queryFn: () => getTodos(search.status), }) ) }, component: TodoListPage, }) function TodoListPage() { const { status } = Route.useSearch() const { data: todos } = useQuery(todosListOptions(status)) // … }
  • A. ensureQueryData 應該改用 prefetchQuery,前者不會存入快取
  • B. validateSearch 的 schema 定義有誤,導致 search params 驗證失敗
  • C. loader 和元件使用的 queryKey 不一致——loader 用 ['todos', 'list', { status }],元件用 ['todos', { status }],快取存在不同的 key 底下
  • D. 元件中應該用 useSuspenseQuery 而非 useQuery,才能讀取 loader 預取的資料

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *