supabase-js CRUD 查詢與寫入 | Supabase 完整教學

2026/10/06
supabase-js CRUD 查詢與寫入 | Supabase 完整教學

上一篇我們認識了 supabase-js 這個統一 client,也知道 supabase.from() 是資料庫子模組的入口。這一篇就把最常用的一塊——CRUD(Create、Read、Update、Delete)——徹底玩熟。你會學到用 insert 新增(單筆與多筆批次)、select 查詢、update 搭配 filter 修改、upsert 靠 onConflict 做「有就更新、沒有就新增」、delete 刪除,並且看懂貫穿全部操作的 { data, error } 錯誤處理模式、為什麼 v2 寫入預設不回傳資料、以及 .single() 與 .maybeSingle() 的差別。

前言

一句話定義本篇主題:CRUD 是 Create(新增)、Read(讀取)、Update(更新)、Delete(刪除)四個資料庫基本操作的合稱,而 supabase-js 用 .insert()、.select()、.update()、.delete()(外加一個 .upsert())把它們包成一致的鏈式呼叫,全部回傳統一的 { data, error } 結構。 學會這四個動詞,你就掌握了應用程式與資料庫之間九成以上的日常互動。

上一篇《supabase-js 客戶端入門》我們建立了 client、認識了它底下的五大子模組,也寫了第一個 from().select() 查詢。但那只是「讀」。真正的應用程式要能新增使用者、更新訂單狀態、刪除留言——這些「寫」的操作,加上更完整的「讀」,才構成一個能用的系統。這一篇就聚焦在 supabase.from() 這一塊,把讀寫操作講到你能安心寫進生產環境。

用一個類比:如果把資料表想像成一個 Excel 工作表,CRUD 就是你對這張表最基本的四種手勢——insert 是在最底下「新增一列」、select 是「圈選並複製某些列出來看」、update 是「點進某幾格改內容」、delete 是「選取某幾列按刪除」。 而 supabase-js 的巧妙之處在於,這四種手勢用的是幾乎一樣的語法起手式(都從 supabase.from('表名') 開始),差別只在後面接哪個動詞、以及要不要加篩選條件。學會這套「文法」,四個操作一次通。

本篇你會學到:

  • insert 新增:單筆與多筆批次寫入,以及為何 v2 預設不回傳資料
  • select 查詢與 returning:讀取資料、寫入後用 .select() 拿回結果
  • update 與 delete:務必搭配 filter,否則會對整張表動手
  • upsert 的 onConflict:一個動作搞定「有就更新、沒有就新增」
  • 錯誤處理與單筆查詢:{ data, error } 模式、.single() 與 .maybeSingle() 的取捨

核心概念

在動手之前,先把三個貫穿全篇的心智模型建立起來。這三點理解了,後面的實作範例你會覺得理所當然。

一、四個動詞,同一套鏈式文法

supabase-js 的所有資料庫操作,都從 supabase.from('資料表') 出發,接著鏈上一個「動詞」,需要的話再鏈上 filter 與 modifier。下表是四個 CRUD 操作與 SQL 的對照:

操作supabase-js對應 SQL是否常配 filter
Create.insert(values)INSERT INTO否(新增不需篩選)
Read.select(columns)SELECT視需要
Update.update(values).eq(...)UPDATE ... WHERE是(必須)
Delete.delete().eq(...)DELETE ... WHERE是(必須)
(複合).upsert(values, { onConflict })INSERT ... ON CONFLICT否

注意 update 與 delete 那兩列標注「必須」配 filter——因為它們少了 WHERE 條件就是「對全表每一列生效」,這是後面〈常見錯誤〉會重點展開的坑。

二、{ data, error }——不 throw,錯誤包在回傳裡

這是使用 supabase-js 最核心的一條規則:資料庫操作不會用 throw 拋出例外,而是把結果與錯誤一起裝在一個物件裡回傳。 每一次 await,你都會拿到形如 { data, error } 的結構:

const { data, error } = await supabase.from('posts').select()
// 成功:data 是資料、error 是 null
// 失敗:data 是 null、error 是 PostgrestError

error 不是普通字串,而是一個 PostgrestError 物件,包含四個實用欄位:

interface PostgrestError {
  message: string   // 人類可讀的錯誤訊息
  details: string   // 詳細說明
  hint: string      // 修正建議
  code: string      // PostgreSQL 錯誤代碼,例如 '23505' 唯一值衝突
}

因此使用這個 SDK 的鐵律是:每次解構出 { data, error } 後,先 if (error) 判斷,再放心用 data。 你不能只靠 try/catch(那攔不到這種「回傳式」錯誤),也不能拿到 data 就直接用(失敗時它是 null)。這一點跟很多用 throw 的函式庫習慣不同,是初學者最常見的坑。

三、v2 寫入預設不回傳——.select() 才是 RETURNING

在 supabase-js v2,insert、update、upsert、delete 這些寫入操作,預設不會回傳受影響的資料(v1 會,這是 v2 的重大改變)。原因是多數寫入根本不需要拿回資料,少傳一趟省頻寬。所以:

// 寫入成功,但 data 是 null(因為沒要求回傳)
const { data, error } = await supabase.from('logs').insert({ msg: 'hi' })
// error === null(成功),data === null(沒 returning)

// 想拿回剛寫入的資料?在鏈尾加 .select()(等同 SQL 的 RETURNING)
const { data: created } = await supabase
  .from('logs').insert({ msg: 'hi' }).select()
// created 是新增後的資料陣列

關鍵術語一次看懂:

  • RETURNING:SQL 語法,讓 INSERT/UPDATE/DELETE 執行後回傳受影響的列。supabase-js 用鏈尾的 .select() 對應這個能力。
  • upsert:insert + update 的合體——資料若已存在(依衝突欄位判斷)就更新、不存在就新增。
  • onConflict:告訴 upsert「用哪個欄位判斷衝突」,通常是主鍵或有 unique 約束的欄位。
  • .single() / .maybeSingle():把回傳從「陣列」收斂成「單一物件」的修飾符,差別在找不到資料時算不算錯誤。

實作範例

以下範例假設你已依上一篇建立好 client(import { supabase } from '@/lib/supabase'),並有一張 posts 表(欄位含 id、title、content、published、author_id)。所有程式碼皆可直接使用。

一、insert 新增單筆

最基本的新增,傳入一個物件。預設不回傳資料,只需檢查 error 判斷成敗:

import { supabase } from '@/lib/supabase'

// 新增單筆(v2 預設不回傳資料)
const { error } = await supabase
  .from('posts')
  .insert({ title: 'Hello Supabase', content: '第一篇文章', published: false })

if (error) {
  console.error('新增失敗:', error.message)
} else {
  console.log('新增成功')  // 注意:這裡的 data 會是 null,屬正常
}

二、insert 新增多筆(批次寫入)

傳入一個陣列就能一次寫入多筆——這是批次寫入,比迴圈逐筆 insert 高效非常多(一趟網路請求解決):

// 批次新增多筆:傳入陣列
const { error } = await supabase
  .from('posts')
  .insert([
    { title: '第一篇', author_id: 'user-1' },
    { title: '第二篇', author_id: 'user-1' },
    { title: '第三篇', author_id: 'user-2' },
  ])

if (error) console.error('批次新增失敗:', error.message)
// 批次寫入是「全有或全無」:只要有一筆違反約束,整批都不會寫入

需要注意批次寫入的原子性:只要其中一筆違反約束(例如某筆 title 是 NOT NULL 卻沒給),整批都會失敗、一筆都不會進去。

三、returning——寫入後用 .select() 拿回資料

若你需要新增後立刻拿回資料(例如取得自動產生的 id 或 created_at),在鏈尾加 .select():

// 新增並回傳全部欄位
const { data, error } = await supabase
  .from('posts')
  .insert({ title: '需要拿回 id 的文章', author_id: 'user-1' })
  .select()

console.log(data)  // [{ id: 42, title: '...', created_at: '2026-10-06...' }]

// 新增單筆並只回傳指定欄位、收斂成單一物件
const { data: post } = await supabase
  .from('posts')
  .insert({ title: '單筆新增', author_id: 'user-1' })
  .select('id, title')
  .single()

console.log(post?.id)  // 直接是 42,而非 [{ id: 42 }]

.select() 後回傳的是陣列;若你確定只新增一筆、想直接拿物件,再接 .single() 把 [{...}] 收斂成 {...}。

四、select 查詢與過濾

讀取的核心是 .select(),搭配 filter 縮小範圍。這裡快速複習幾個常用寫法:

// 查全部欄位
const { data: all } = await supabase.from('posts').select()

// 只查指定欄位
const { data: cols } = await supabase.from('posts').select('id, title, published')

// 加 filter:只撈已發布的文章
const { data: pub } = await supabase
  .from('posts')
  .select('id, title')
  .eq('published', true)

// 多個 filter 預設是 AND;再加排序與筆數限制
const { data: recent } = await supabase
  .from('posts')
  .select()
  .eq('published', true)
  .eq('author_id', 'user-1')
  .order('created_at', { ascending: false })
  .limit(10)

常用 filter 包括 .eq()(等於)、.neq()(不等於)、.gt()/.gte()/.lt()/.lte()(比較)、.like()/.ilike()(模糊比對)、.in()(在清單中)、.is('col', null)(判斷 NULL)等。這些 filter 可以自由串接,多個條件之間預設是 AND 邏輯——也就是「同時滿足」,語意直觀好記。要注意判斷 NULL 一定要用 .is('col', null) 而非 .eq('col', null),因為 SQL 裡 = NULL 永遠不成立,這是新手很容易踩的細節。

五、update 更新(務必搭配 filter)

update 傳入「要改成什麼」的物件,並且一定要接 filter 指明「改哪些列」:

// 把 id=42 的文章標記為已發布
const { error } = await supabase
  .from('posts')
  .update({ published: true })
  .eq('id', 42)

// 更新並拿回更新後的資料:加 .select()
const { data: updated } = await supabase
  .from('posts')
  .update({ title: '改過的標題', updated_at: new Date().toISOString() })
  .eq('id', 42)
  .select()
  .single()

console.log(updated?.title)  // '改過的標題'

// 也可以批次更新:把某作者所有草稿一次發布
const { error: batchErr } = await supabase
  .from('posts')
  .update({ published: true })
  .eq('author_id', 'user-1')
  .eq('published', false)

再次強調:.update({...}) 後面若忘了接 .eq()(或任何 filter),語意就是「更新整張表每一列」——這是致命坑,下一節詳談。

六、upsert 插入或更新(onConflict)

upsert 是「有就更新、沒有就新增」的合體操作,靠 onConflict 指定「用哪個欄位判斷是否已存在」:

// 依主鍵 upsert:id 存在則更新,不存在則新增
const { data, error } = await supabase
  .from('profiles')
  .upsert({ id: 'user-1', username: 'ben', updated_at: new Date().toISOString() })
  .select()

// 依非主鍵的 unique 欄位判斷衝突:用 onConflict 指定 username
const { error: e2 } = await supabase
  .from('profiles')
  .upsert(
    { username: 'ben', email: 'ben@example.com' },
    { onConflict: 'username' }   // 依 username 判斷是否已存在
  )

// 批次 upsert:一次同步多筆(常用於資料同步、匯入)
const { error: e3 } = await supabase
  .from('profiles')
  .upsert([
    { id: 'user-1', username: 'ben' },
    { id: 'user-2', username: 'amy' },
  ])

// 只想「不存在才新增、存在就跳過(不更新)」:ignoreDuplicates
const { error: e4 } = await supabase
  .from('profiles')
  .upsert(
    { username: 'ben', email: 'ben@example.com' },
    { onConflict: 'username', ignoreDuplicates: true }
  )

onConflict 指定的欄位必須有主鍵或 unique 約束,否則資料庫無從判斷「衝突」。upsert 特別適合「同步」場景——例如把外部資料定期匯入,存在的更新、新的插入,一個呼叫搞定,不必先查一次再決定要 insert 還是 update,省下往返也避免了「查完到寫入之間資料被別人改掉」的競態問題。批次 upsert 更是資料匯入的利器:一趟請求就能把上百筆資料「有則更新、無則新增」,效能遠勝逐筆判斷。

七、delete 刪除(同樣務必搭配 filter)

delete 不傳資料,直接接 filter 指明「刪哪些列」:

// 刪除 id=42 這一筆
const { error } = await supabase
  .from('posts')
  .delete()
  .eq('id', 42)

// 刪除並拿回被刪掉的資料(加 .select())
const { data: removed } = await supabase
  .from('posts')
  .delete()
  .eq('id', 42)
  .select()

// 批次刪除:刪掉多個特定 id
const { error: e2 } = await supabase
  .from('posts')
  .delete()
  .in('id', [1, 2, 3])

跟 update 一樣,.delete() 後面沒有 filter 就是刪光整張表。這條規則值得刻進肌肉記憶。

八、single 與 maybeSingle 的差別

當你預期查詢結果只有一筆時,用 .single() 或 .maybeSingle() 把陣列收斂成單一物件,兩者差在「找不到」算不算錯誤:

// single():預期「剛好一筆」。0 筆或 2 筆以上都會回傳 error(code 'PGRST116')
const { data, error } = await supabase
  .from('posts')
  .select()
  .eq('id', 42)
  .single()
// data 是單一物件(非陣列);找不到 → error 有值

// maybeSingle():預期「0 或 1 筆」。找不到時 data 為 null 且不報錯
const { data: profile, error: e2 } = await supabase
  .from('profiles')
  .select()
  .eq('email', 'maybe@example.com')
  .maybeSingle()
// 找不到 → data 為 null、e2 為 null(不當成錯誤)

取捨原則:如果「找不到」代表出了問題(例如用已知 id 查主鍵),用 .single() 讓它報錯;如果「找不到」是正常情況(例如檢查某 email 是否已註冊),用 .maybeSingle() 讓 data 平靜地回傳 null。

常見錯誤與最佳實踐

坑一:update / delete 忘了帶 filter,對整張表動手。 這是 CRUD 操作最危險的錯誤。supabase.from('posts').delete() 不接任何 filter,語意就是「刪光整張 posts 表」;.update({ archived: true }) 沒 filter 就是「把每一列都改掉」。在本機用 service_role key 測試時尤其致命,因為它繞過 RLS、毫無防護網。正確做法:養成「寫 update/delete 一定先寫 filter」的肌肉記憶,把「沒帶 filter 的寫入」視為 code review 紅線。真要清空表時用明確的條件或資料庫端 TRUNCATE,別靠「剛好沒寫 filter」。(前端用 anon key 時,RLS 政策通常會把可影響範圍限縮成「符合政策的列」,這是 RLS 幫你擋,但不代表你的程式碼寫對了。)

坑二:沒檢查 error 就直接用 data。 supabase-js 不 throw,錯誤包在 { data, error } 裡。若你解構後不判斷 error 就用 data,一旦失敗 data 是 null,接著 data.map(...) 就會噴 Cannot read property 'map' of null。正確做法:每次 await 解構後,先 if (error) { ... return } 再用 data,這是使用 SDK 最基本的紀律。

坑三:以為錯誤會被 try/catch 攔到。 把 supabase 查詢包在 try/catch 裡、卻不檢查 error,是常見誤解——資料庫操作的錯誤是「回傳」而非「拋出」,catch 區塊根本不會執行(除非是網路層徹底斷線之類的例外)。正確做法:以 if (error) 為主要錯誤處理手段;try/catch 留給你自己在 if (error) throw ... 之後、想在更上層統一攔截時使用。

坑四:v2 寫入後發現 data 是 null,誤以為寫入失敗而重跑。 v2 寫入預設不回傳資料,成功時 error 為 null 但 data 也是 null。若你以「data 有沒有值」判斷成敗,就會誤判成失敗、重複執行,結果插入了兩筆。正確做法:判斷寫入成敗只看 error 是不是 null;需要拿回資料時才加 .select()。

坑五:該用 maybeSingle() 的地方用了 single(),害「查無資料」被當成錯誤。 用 .single() 查一個可能不存在的資料(例如檢查 email 是否註冊),找不到時會回傳 PGRST116 錯誤,讓你的「正常查無」變成要處理的例外。正確做法:「找不到是正常」用 .maybeSingle()(回傳 null);「找不到是異常」才用 .single()(報錯)。

最佳實踐總結:

  • update/delete 必配 filter:把「沒帶 filter 的寫入」當紅線
  • 永遠先檢查 error:不 throw,錯誤在回傳物件裡
  • 判斷成敗看 error 而非 data:v2 寫入預設不回傳
  • 要 returning 才加 .select():對應 SQL 的 RETURNING
  • 單筆查詢選對修飾符:正常查無用 .maybeSingle()、異常查無用 .single()
  • 批次寫入用陣列:一趟請求勝過迴圈逐筆,且具原子性

小結

這是 Supabase 系列教學的第 017 篇,延續上一篇《supabase-js 客戶端入門》——我們認識了 client 這個總機、也知道 supabase.from() 是資料庫子模組的入口,這一篇就把最常用的 CRUD 讀寫操作徹底補齊。

核心觀念濃縮成一句話:用 supabase.from('表名') 起手,接 .insert()(新增,可傳陣列批次寫入)、.select()(查詢,也是寫入後拿回資料的 RETURNING)、.update()(更新,務必配 filter)、.upsert()(靠 onConflict 做插入或更新)、.delete()(刪除,同樣務必配 filter),全部回傳統一的 { data, error }——永遠先檢查 error 再用 data,判斷寫入成敗只看 error,需要單筆時視「查無」是否正常選用 .single() 或 .maybeSingle()。

  • 四個動詞一套文法:from() 起手,接動詞與 filter
  • { data, error } 不 throw:先檢查 error 是最基本紀律
  • v2 寫入預設不回傳:要資料就加 .select()
  • update/delete 必配 filter:否則全表操作,致命坑
  • .single() vs .maybeSingle():差在查無算不算錯誤

把 CRUD 玩熟之後,接下來要讓這一切型別安全。下一篇《型別生成與進階用法》,我們會用 Supabase CLI 從資料庫 schema 生成 TypeScript 型別,把 createClient<Database> 的威力發揮出來——讓 insert 少給欄位、select 取錯欄名這類錯誤,在編譯階段就被 IDE 攔下來,而不是等到執行時才炸。下一篇見。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →