Supabase Storage 物件儲存入門:上傳、取用檔案 | Supabase 完整教學
登入的故事講完了,這一篇換上全新主題:檔案。Supabase Storage 是 Supabase 內建的**物件儲存(Object Storage)**服務——圖片、影片、PDF 這類「不適合塞進資料表」的檔案,都交給它保管。本篇帶你從零建立 bucket、用
upload上傳、用getPublicUrl與createSignedUrl取用、用list/remove管理,並學會user_id/檔名的路徑慣例與大小、MIME 限制。這是 Storage 段的第一篇。
前言
一句話定義本篇主題:Supabase Storage 是一套「以 Postgres 為大腦」的物件儲存服務——你把檔案(圖片、影片、文件)丟給它保管,它把每個檔案的「身分資料」(路徑、大小、MIME type)記在 Postgres 的 storage schema,因此你熟悉的 RLS 那一套,可以原封不動拿來控管檔案存取。 檔案的實體放在物件後端(S3/磁碟),中繼資料放在 Postgres,兩者一體。
先釐清一個很多人搞混的地方:資料庫(前面 001–022 篇談的 Table)和 Storage 是兩種不同的東西。 資料庫存的是「結構化的一行行資料」——使用者名稱、訂單金額、留言內容;Storage 存的是「一整個檔案」——一張 5MB 的照片、一份 PDF 合約。你不會把照片的二進位塞進資料表的欄位(那既慢又貴),而是把照片交給 Storage,只在資料表存一個「指向這張照片的路徑字串」。兩者分工,各司其職。
用一個生活類比幫你抓住 Storage 的全貌:Storage 就像一間「附設寄物櫃的圖書館」。
- bucket(儲存桶) 就像圖書館裡不同用途的區域——「公開展示區」(public bucket,任何人路過都能看)和「私人保險箱區」(private bucket,要憑證才能開)。
- 上傳(upload) 就是把一份文件放進某個區域的某個格子。
- 路徑(path) 就是格子的門牌號碼,例如
你的會員編號/照片.jpg。 getPublicUrl是公開展示區的「永久導覽指示牌」——直接告訴任何人「東西在這,自己去看」。createSignedUrl是私人保險箱區的「限時通行證」——館員發給你一張「一小時內有效」的臨時卡,過期作廢。
抓住「bucket 分公開/私人、檔案靠路徑定位、取用分永久網址與限時簽名」這三件事,Storage 入門就通了。
本篇你會學到:
- bucket 的兩種型態:public vs private 的差別(只差在「讀取要不要認證」),以及怎麼用 SDK 建立
- 檔案的 CRUD:
upload(上傳)、download(下載)、list(列出)、remove(刪除) - 兩種取用網址:public bucket 用
getPublicUrl(同步、永久),private bucket 用createSignedUrl(非同步、限時) - 路徑慣例:為什麼要用
user_id/檔名,它如何替下一篇的權限控管鋪路 - 上傳限制:bucket 的
fileSizeLimit(大小)與allowedMimeTypes(MIME 型別)怎麼設
核心概念
Storage 入門只需要掌握兩張心智圖:一張是「bucket 的 public/private 差在哪」,另一張是「上傳與取用的完整流程長怎樣」。
一、bucket:檔案的容器,先分公開或私人
bucket(儲存桶)是 Storage 裡最上層的容器,所有檔案都住在某個 bucket 裡。建立時最重要的一個決定就是:它是 public 還是 private。
| Public Bucket | Private Bucket | |
|---|---|---|
| 直接用網址讀取 | 允許,無需認證 | 不行,需簽名連結或帶認證 |
| 取用網址的方法 | getPublicUrl()(同步、永久) | createSignedUrl()(非同步、限時) |
| 適用場景 | Logo、行銷圖、公開產品圖 | 使用者私人文件、付費內容、發票 |
| 寫入(上傳/刪除) | 仍受 RLS 政策保護 | 仍受 RLS 政策保護 |
這張表最關鍵的一行是最後一行:public 只影響「讀取」要不要認證,不影響「寫入」。 換句話說,把 bucket 設成 public 不等於任何人都能往裡面丟檔案或刪檔案——上傳、刪除這些寫入操作,無論 public 還是 private,都一樣要靠 RLS 政策放行。public 放寬的,只有「讀」這一件事。
判斷 public 還是 private,問自己一句話就好:這個檔案「被陌生人看到會不會有問題」? 不會(Logo、公開圖)→ public;會(私人文件、付費內容)→ private。
二、上傳與取用的完整流程
把一個檔案從「使用者選檔」到「別人能看到」的完整旅程畫出來,你就懂 Storage 的全貌了:
【上傳階段】
使用者選檔 (File)
│
▼
supabase.storage.from('bucket').upload(path, file)
│ RLS INSERT 政策驗證通過 → 檔案存入 bucket
▼
storage.objects 記錄一筆中繼資料(路徑、大小、MIME)
【取用階段】
┌─ public bucket ──→ getPublicUrl(path) → 永久公開網址(同步、無需認證)
│
└─ private bucket ─→ createSignedUrl(path, expiresIn) → 限時簽名網址(非同步、含 token)
流程的重點在「取用階段的岔路」:同樣是「拿到一條能看檔案的網址」,public bucket 和 private bucket 走的是兩條完全不同的路。
- public bucket →
getPublicUrl(path)。這只是「字串拼接」,SDK 在本地把.../object/public/bucket/path這串固定格式的網址組出來,不連網、同步回傳、永久有效。 - private bucket →
createSignedUrl(path, expiresIn)。這是「真的去問伺服器要一段簽名」,Storage 用內部金鑰對「這個路徑 + 這個效期」簽出一條帶token的網址,需要一次網路請求,所以要await,且網址到期就失效。
三、關鍵術語小抄
| 術語 | 意義 |
|---|---|
| Object Storage | 物件儲存,以「整個檔案」為單位存取(相對於資料表的一行行資料) |
| bucket | 儲存桶,Storage 最上層的容器,分 public/private |
| path | 檔案在 bucket 內的路徑,例如 user_id/avatar.jpg |
upload / download | 上傳檔案 / 下載檔案(回傳 Blob) |
list / remove | 列出資料夾內物件 / 刪除物件 |
getPublicUrl | 取得 public bucket 的永久公開網址(同步,無 error) |
createSignedUrl | 取得 private bucket 的限時簽名網址(非同步,需 await) |
fileSizeLimit / allowedMimeTypes | bucket 層級的檔案大小上限 / 允許的 MIME 型別 |
| MIME type | 檔案型別標記,如 image/png、application/pdf |
實作範例
以下用 supabase-js(TypeScript)從頭到尾示範一遍:建立 bucket → 上傳 → 取用(public 與 private 兩種)→ 列出 → 刪除。API 名稱以官方文件為準。
一、建立 bucket(public 與 private)
第一步是建立 bucket。你可以在 Dashboard 點按鈕建,也可以用 SDK 建——後者適合寫進初始化腳本。建立 private bucket 時,順手把「大小上限」和「允許的 MIME 型別」也設好:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
// 建立「公開」bucket:放 Logo、行銷圖這類公開素材
const { data: pubBucket, error: pubErr } = await supabase
.storage
.createBucket('avatars', {
public: true, // 讀取無需認證,可用 getPublicUrl 取永久網址
})
// 建立「私有」bucket,並限制檔案大小與 MIME 型別
const { data: privBucket, error: privErr } = await supabase
.storage
.createBucket('documents', {
public: false, // 讀取需簽名連結
fileSizeLimit: '10MB', // 單檔上限 10MB(也可傳 bytes:10485760)
allowedMimeTypes: ['application/pdf', 'image/*'], // 只允許 PDF 與各種圖片
})
if (privErr) console.error('建立 bucket 失敗:', privErr.message)
三個要點:一、public: true/false 是決定公開或私有的唯一開關;二、fileSizeLimit 接受 '10MB' 這種字串或純數字 bytes,超過上限的上傳會回傳 413 Payload Too Large;三、allowedMimeTypes 用萬用字元 image/* 可涵蓋所有圖片格式,不符的檔案上傳會被擋下(400 MIME type not allowed)。
二、上傳檔案:upload
有了 bucket,就能上傳。核心方法是 supabase.storage.from('bucket').upload(path, file)——path 是你要放的路徑,file 是檔案本身(瀏覽器的 File 物件):
// 情境:使用者上傳大頭照到 avatars bucket
async function uploadAvatar(userId: string, file: File) {
// 路徑慣例:user_id 當第一層資料夾,替後續 RLS 權限鋪路(下一篇詳談)
const ext = file.name.split('.').pop() // 取副檔名,如 'png'
const filePath = `${userId}/avatar.${ext}` // 例如 8f3a.../avatar.png
const { data, error } = await supabase
.storage
.from('avatars')
.upload(filePath, file, {
contentType: file.type, // 明確指定 MIME type(如 image/png)
cacheControl: '3600', // 瀏覽器/CDN 快取秒數,預設 3600
upsert: false, // false = 路徑已存在就報錯;true = 覆寫
})
if (error) {
console.error('上傳失敗:', error.message)
return null
}
console.log('上傳成功,路徑:', data.path) // avatars/8f3a.../avatar.png
return data.path
}
三個選項值得記住:一、contentType 明確指定 MIME type,避免 SDK 從副檔名猜錯;二、cacheControl 控制快取秒數,靜態素材(Logo)可設很長、常變動的內容設短;三、upsert 預設是 false——同一個路徑上傳第二次會直接報 400 Asset Already Exists。 想覆寫就設 upsert: true,但要知道覆寫會踩到 CDN 快取問題(後面「常見錯誤」會談)。
三、public bucket 取用:getPublicUrl(同步、永久)
上傳到 public bucket 後,取用最簡單——getPublicUrl 直接給你一條永久公開網址。注意它是同步函式,不用 await,也沒有 error:
// public bucket:取得永久公開網址(同步,無需 await)
function getAvatarUrl(userId: string) {
const { data } = supabase
.storage
.from('avatars')
.getPublicUrl(`${userId}/avatar.png`)
// data.publicUrl 是可預測的固定格式,任何人都能直接開
// https://<project>.supabase.co/storage/v1/object/public/avatars/<userId>/avatar.png
return data.publicUrl
}
// 直接用在 <img> 上
const avatarUrl = getAvatarUrl('8f3a-...')
// <img src={avatarUrl} alt="使用者大頭照" />
為什麼不用 await?因為這串網址是「算出來的」不是「問來的」——SDK 光靠 bucket 名稱和路徑就能在本地拼出這串固定格式的網址,完全不碰網路,所以同步回傳、也不會有 error。這條網址永久有效(除非你刪檔),而且會被全球 CDN 快取,讀取很快。
四、private bucket 取用:createSignedUrl(非同步、限時)
private bucket 的檔案沒有公開網址,想給別人看,必須用 createSignedUrl(path, expiresIn) 發一條「限時簽名連結」。它是非同步的,要 await,而且有 error:
// private bucket:建立效期 1 小時(3600 秒)的簽名網址
async function getSignedDocUrl(userId: string, fileName: string) {
const filePath = `${userId}/${fileName}`
const { data, error } = await supabase
.storage
.from('documents')
.createSignedUrl(filePath, 3600) // 第二個參數是「有效秒數」
if (error) {
console.error('簽名網址建立失敗:', error.message)
return null
}
// data.signedUrl 含 token,1 小時後自動失效
// https://<project>.supabase.co/storage/v1/object/sign/documents/...?token=...
return data.signedUrl
}
// 想直接把檔案下載回本地(回傳 Blob)而非給網址,用 download:
async function downloadDoc(userId: string, fileName: string) {
const { data, error } = await supabase
.storage
.from('documents')
.download(`${userId}/${fileName}`)
if (error) throw error
return data // Blob,可用 URL.createObjectURL(data) 觸發下載
}
createSignedUrl 的第二個參數 expiresIn 是「這條連結能活幾秒」——3600 是 1 小時,300 是 5 分鐘。效期越短越安全:一條簽名連結一旦產生就無法主動撤銷,只能等它自然過期,所以對敏感內容請設短一點。至於 download,它跟 createSignedUrl 的差別是「直接把檔案內容抓回來變成 Blob」,適合你想在程式裡處理檔案、或觸發瀏覽器另存的場景。
五、列出與刪除:list 與 remove
管理檔案靠 list(列出某資料夾下的物件)和 remove(刪除,接受路徑陣列,可批次):
// 列出某使用者資料夾下的所有檔案
async function listUserFiles(userId: string) {
const { data, error } = await supabase
.storage
.from('documents')
.list(userId, { // 第一個參數是「資料夾前綴」
limit: 100,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' }, // 依建立時間新→舊
})
if (error) throw error
// data: [{ name, id, created_at, updated_at, metadata }, ...]
return data
}
// 刪除檔案:remove 接受「路徑陣列」,可一次刪多個
async function deleteFiles(userId: string) {
const { data, error } = await supabase
.storage
.from('documents')
.remove([
`${userId}/old-report.pdf`,
`${userId}/draft.pdf`,
])
if (error) throw error
console.log('已刪除:', data)
return data
}
兩個小提醒:一、list 的第一個參數是資料夾前綴(不是完整檔案路徑),傳 userId 就會列出該使用者資料夾下的東西;傳空字串 '' 則列出根層。二、remove 吃的是陣列,就算只刪一個檔案也要包成 ['path'],這個設計讓批次刪除很方便。
六、路徑慣例:為什麼要用 user_id/檔名
前面每個範例的路徑都刻意用 ${userId}/檔名 開頭,這不是隨手寫的——它是替下一篇的權限控管鋪路。 對比一下兩種命名:
// 推薦:以 user_id 當第一層資料夾
avatars/8f3a-1b2c/avatar.png
documents/8f3a-1b2c/2026-q4-report.pdf
uploads/8f3a-1b2c/a1b2c3-invoice.pdf // 加 UUID 前綴避免同名覆蓋
// 不推薦:扁平結構(難以用 RLS 精準控管)
avatar123.png
report.pdf
推薦寫法的好處:Supabase Storage 的權限是靠 Postgres RLS 政策,而政策最可靠的判斷依據就是「路徑的第一層資料夾」。把每個人的檔案都放在以他 user_id 命名的資料夾下,之後就能寫一條政策說「使用者只能碰第一層資料夾等於自己 user_id 的檔案」,一行搞定「每人只能存取自己的東西」。此外 user_id 前綴天然避免了不同使用者檔名撞名。這套路徑慣例,就是下一篇《Storage 權限與存取控制》的地基。
常見錯誤與最佳實踐
坑一:對 private bucket 用 getPublicUrl。
這是新手最常見的錯。getPublicUrl 會「照樣幫你拼出一串網址」——它不連網、不檢查 bucket 是不是 public,所以你不會收到任何 error,程式看起來一切正常。但當你把這串網址貼到瀏覽器,會得到 400/Object not found,因為 private bucket 的檔案根本不接受公開存取。正確做法:private bucket 一律用 createSignedUrl(path, expiresIn) 取限時簽名網址;getPublicUrl 只給 public bucket 用。記法——public 配 getPublicUrl,private 配 createSignedUrl,別交叉。
坑二:upsert 沒設好,上傳同路徑直接報錯。
upload 的 upsert 預設是 false,代表「同一個路徑上傳第二次會失敗」(400 Asset Already Exists)。很多人第一次上傳成功、第二次換頭像卻報錯,就是踩到這個。正確做法:想覆寫舊檔就明確設 upsert: true;但更推薦的做法是根本別覆寫——改用帶時間戳或 UUID 的唯一路徑(例如 ${userId}/${Date.now()}.jpg)。原因見下一坑。
坑三:覆寫檔案後,使用者看到的還是舊圖(CDN 快取)。
就算你用 upsert: true 成功覆寫了同一路徑的檔案,全球 CDN 上可能還快取著舊版本,最多要等一段時間才失效——結果使用者換了頭像卻還看到舊的。正確做法:對「會更新」的內容,上傳到新路徑而非覆寫(例如路徑帶 Date.now()),新路徑等於新網址,天然繞過快取;或退而求其次在網址後加版本參數 ?v=2 強制重抓。
坑四:忘記 bucket 的大小/MIME 限制,上傳大檔或錯型別被擋。
若你在 createBucket 時設了 fileSizeLimit: '10MB',一個 20MB 的檔案上傳會回 413 Payload Too Large;設了 allowedMimeTypes: ['application/pdf'],上傳 PNG 會回 400 MIME type not allowed。這其實是保護機制,不是 bug——它幫你擋掉超大垃圾檔和非預期型別。正確做法:依業務需求設合理上限(大頭照 5MB、文件 10–50MB),並在前端上傳前就先檢查 file.size 與 file.type 給使用者友善提示,別讓錯誤直接丟到伺服器才回。
最佳實踐總結:
- public 配
getPublicUrl、private 配createSignedUrl:別交叉用,這是取用網址的鐵律 getPublicUrl同步無 error、createSignedUrl要 await 有 error:一個是本地拼字串、一個是問伺服器- 簽名連結設短效期:無法主動撤銷,敏感內容用
300~3600秒 - 路徑用
user_id/檔名:替下一篇的 RLS 權限鋪路,順便避免撞名 - 會更新的內容用新路徑而非覆寫:繞過 CDN 快取,別讓使用者看到舊圖
- bucket 層級設好
fileSizeLimit/allowedMimeTypes:從源頭擋掉大檔與錯型別
小結
這是 Supabase 系列教學的第 024 篇,也是全新 Storage 段的開篇。上一篇《MFA 與 SSR》我們替 Auth 段收了尾——用 MFA 把 session 升級到 aal2、用 @supabase/ssr 在伺服器端安全保管 session。登入的故事到那裡講完,這一篇我們換到「檔案」的世界:如何用 Supabase Storage 上傳、管理、取用檔案。
核心觀念濃縮成一句話:Storage 是「以 Postgres 為大腦」的物件儲存,檔案住在 bucket 裡,bucket 分 public(讀取免認證、用同步的 getPublicUrl 取永久網址)與 private(讀取需認證、用非同步的 createSignedUrl 發限時簽名連結);上傳用 upload(path, file)、列出用 list、刪除用 remove,而路徑一律採 user_id/檔名 慣例,替權限控管鋪路。
- bucket 兩型態:public(讀取免認證)vs private(讀取需簽名),寫入兩者都受 RLS 保護
- 檔案 CRUD:
upload(上傳)/download(下載 Blob)/list(列出)/remove(批次刪除) - 取用網址:public 用
getPublicUrl(同步、永久、無 error)、private 用createSignedUrl(await、限時、有 error) - 路徑慣例:
user_id/檔名,讓 RLS 能靠第一層資料夾精準控管 - 上傳限制:
fileSizeLimit(大小)與allowedMimeTypes(MIME 型別)從源頭把關
到這裡你已經會建 bucket、上傳、取用、管理檔案了——但目前的存取控制還很粗(public 全公開、private 靠簽名連結)。真正細緻的「誰能讀、誰能寫、誰只能碰自己的檔案」,得靠你在 009–011 篇熟透的 RLS 下沉到 storage.objects 資料表。下一篇《Storage 權限與存取控制》,我們就要把 RLS 政策套到 Storage 上,讓每個檔案的存取都精準受控。上傳學會了,接著聊「怎麼守好每一個檔案」。下一篇見。