Supabase Storage 物件儲存入門:上傳、取用檔案 | Supabase 完整教學

2026/10/13
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 BucketPrivate 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 / allowedMimeTypesbucket 層級的檔案大小上限 / 允許的 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 上,讓每個檔案的存取都精準受控。上傳學會了,接著聊「怎麼守好每一個檔案」。下一篇見。

BenZ Software Developer

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

本週主打

AI 自動化入門包

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

看看這個產品 →