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 攔下來,而不是等到執行時才炸。下一篇見。