# Supabase Storage 実装ガイド：アップロード経路の選定・署名URL・CDN・画像変換を本番品質で使う

> Supabase Storageを本番で使い切るための実装ガイド。6MBを境にした標準アップロードとTUS再開可能アップロードの使い分け、サーバーを経由しない署名付きアップロードURL、S3互換エンドポイント、公開URLと署名URLの選択、Smart CDNのキャッシュ挙動と60秒の伝播、画像変換の上限とコスト、そして転送量（egress）を桁で下げる設計まで、公式ドキュメント準拠の実コードで解説します。

- 公開日: 2026-08-16
- 著者: 友田 陽大
- タグ: Supabase, Next.js, TypeScript, パフォーマンス, コスト最適化, セキュリティ
- URL: https://tomodahinata.com/blog/supabase-storage-production-implementation-guide-uploads-signed-urls-cdn-image-transformations
- カテゴリ: データベース・RLS
- 総合ガイド: https://tomodahinata.com/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## 要点

- アップロード経路は4つある。6MB以下は標準アップロード、6MB超はTUS再開可能アップロード、サーバーを経由させたくないなら署名付きアップロードURL、既存のS3ツールチェーンがあるならS3互換エンドポイント
- TUSのチャンクサイズは6MBで固定（公式が『現時点では変更しないこと』と明記）。同一パスへの同時アップロードは409になる
- 公開バケットのファイルはURLを知る全員が読める。RLSは読み取りに効かない。機密ファイルは非公開バケット＋期限付き署名URLで配る
- Smart CDNはPro以上で自動有効。更新・削除の反映には最大60秒かかるため、上書き前提の設計ではキャッシュ整合を必ず検証する
- 画像変換は幅・高さ1〜2500、品質1〜100（既定80）、25MB/50MPが上限でPro以上。転送量の大半は画像なので、変換とcacheControlが最も効くコスト対策になる

---

Supabase Storageは、`upload()` の1行でファイルが保存できます。だから多くのプロジェクトが**その1行のまま本番に出て**、次の3つで詰まります。

1. 大きなファイルが**途中で切れてやり直しになる**（標準アップロードは再開できない）
2. アップロードのたびにサーバー関数を経由して、**実行時間と転送量を二重に払っている**
3. 画像をそのまま配って、**転送量の請求が跳ねる**

この記事は、Supabase Storageを**本番品質**にするための実装ガイドです。公式ドキュメント（2026-08-16時点）に忠実に、4つのアップロード経路の選び方、サーバーを経由しない署名付きアップロード、配信とCDNの挙動、画像変換の上限とコスト、そして転送量を桁で下げる設計までを、そのまま使えるコードで扱います。

> **この記事が扱わないこと**：Storageの**アクセス制御**（`storage.objects` へのRLS、`storage.foldername(name)` によるユーザー別フォルダ、所有者スコープ）は、[Supabase StorageをRLSで守る](/blog/supabase-storage-rls-access-control-bucket-folder-policies-guide)で1本まるごと扱っています。本記事はRLSが正しく設計されている前提で、**その上のアップロード・配信・コスト設計**に集中します。

---

## 1. モデルとバケット：3種類ある

Supabase Storageのバケットは現在3種類あります。

| 種類 | 用途（公式の定義） |
| --- | --- |
| **Files buckets** | 「画像・動画・ドキュメント・汎用ファイルを保存し配信する」 |
| **Analytics buckets** | 「Apache Icebergテーブルでデータを保存する。データレイク、ログ、Supabase Pipelines向け」 |
| **Vector buckets** | 「埋め込みを保存し類似検索を実行する。セマンティックマッチング、AI、RAG向け」 |

この記事が扱うのは **Files buckets** です。特徴は次の通りです。

- 「S3互換ストレージ、RESTful API、TUS再開可能アップロード」
- 「世界285以上の都市から高速に配信」
- 「オンザフライでのリサイズ・圧縮・変換」
- 「行レベルセキュリティとカスタムポリシーでファイル権限を管理」

**最後の1行が本質**です。Supabase Storageのファイルは `storage.objects` テーブルの行であり、フォルダは仮想（パス文字列の一部）です。だからアクセス制御がRLSで書ける。この設計を理解していれば、後の判断がすべて素直につながります。

### バケットの作成と制限

```ts
const { data, error } = await supabase.storage.createBucket('avatars', {
  public: true,
  allowedMimeTypes: ['image/*'],
  fileSizeLimit: '1MB',
})
```

公式の説明では、この設定で「ユーザーが `avatars` バケットに画像だけをアップロードでき、サイズは1MBを超えられない」ようにできます。制限に合わないアップロードは拒否されます。

グローバルのファイルサイズ上限はプランで決まります。

| プラン | グローバル上限 |
| --- | --- |
| Free | 50 MB |
| Pro | 500 GB |
| Team | 500 GB |
| Enterprise | カスタム |

バケット単位の上限は**グローバル上限を超えられません**。公式の推奨はこうです。

> グローバル上限は、アプリケーションが受け入れる最大のファイルサイズに設定し、必要に応じてバケットごとにより小さい上限を設定するべき。

**設計としてはこう読みます**：グローバルは「システム全体の天井」、バケットは「用途ごとの現実的な上限」。`avatars` に500GBのファイルを送れる状態を放置しない、ということです。これは単なる制限ではなく、**転送量コストの防波堤**でもあります。

---

## 2. アップロード経路の選定：4つある

ここが最初の設計判断です。

| 経路 | 使う場面 | 実装 |
| --- | --- | --- |
| **標準アップロード** | 6MB以下の小さいファイル | `supabase.storage.from(...).upload()` |
| **TUS 再開可能アップロード** | 6MB超、不安定な回線、進捗表示が要る | `tus-js-client` / Uppy |
| **署名付きアップロードURL** | サーバーが許可判断をし、バイト列はクライアントから直送したい | `createSignedUploadUrl()` + `uploadToSignedUrl()` |
| **S3互換エンドポイント** | 既存のS3ツールチェーン（AWS SDK、CLI、バックアップツール）がある | AWS SDK `S3Client` |

### 2-1. 標準アップロード

```ts
const { data, error } = await supabase.storage
  .from('bucket_name')
  .upload('file_path', file, {
    // 既存ファイルを上書きする。false（既定）なら 400 Asset Already Exists
    upsert: false,
    // 省略時、Storageは拡張子からContent-Typeを推測する
    contentType: 'image/jpeg',
  })
```

公式の位置づけはこうです。

> 標準のファイルアップロードは、6MBを超えない小さいファイルに最適。

標準アップロードでも最大5GBまで送れますが、**再開できません**。公式は「より高い信頼性のため、6MBを超えるファイルにはTUS再開可能アップロードの使用を推奨」としています。

`contentType` は省略できますが、**省略しないほうがよい**です。拡張子からの推測に頼ると、拡張子のないファイルや偽装された拡張子で意図しない型が付きます。そして「クライアントが宣言した `contentType` は信用できる値ではない」ことも同時に覚えておいてください——**バケットの `allowedMimeTypes` と、必要ならサーバー側の実バイト検査**で二重に守ります。

### 2-2. TUS 再開可能アップロード

Supabase StorageはTUSプロトコルを実装しています。`tus-js-client` または Uppy から使えます。

```js
var upload = new tus.Upload(file, {
    // 直接のストレージホスト名を使う（パフォーマンスのため）
    endpoint: `https://${projectId}.storage.supabase.co/storage/v1/upload/resumable`,
    retryDelays: [0, 3000, 5000, 10000, 20000],
    headers: {
        authorization: `Bearer ${session.access_token}`,
        'x-upsert': 'true',
    },
    uploadDataDuringCreation: true,
    removeFingerprintOnSuccess: true,
    metadata: {
        bucketName: bucketName,
        objectName: fileName,
        contentType: 'image/png',
        cacheControl: '3600',
        metadata: JSON.stringify({yourCustomMetadata: true}),
    },
    chunkSize: 6 * 1024 * 1024,
    onError: function (error) {},
    onProgress: function (bytesUploaded, bytesTotal) {},
    onSuccess: function () {},
})
```

**絶対に守る制約**が1つあります。公式の表現をそのまま引きます。

> チャンクサイズは6MBに設定しなければならない（現時点では）。変更しないこと。

もう1つ、同時実行の挙動です。同じアップロードURLに同時にアップロードできるクライアントは1つだけで、同一パスに複数のクライアントがアップロードすると **409 Conflict** が返ります。最初に完了したものが成功します（`x-upsert` ヘッダを付けた場合は最後のもの）。

**この409は、UIで「アップロードに失敗しました」と出して終わらせてはいけないエラー**です。「同じファイルを別の端末でアップロード中」という状態を意味するので、ユーザーに何が起きているかを伝える必要があります。

### 2-3. 署名付きアップロードURL：サーバーを細くする

「アップロードの許可判断はサーバーでしたいが、バイト列はサーバーを通したくない」——これが最も実用的なパターンです。サーバー関数の実行時間・メモリ・転送量を消費せずに済みます。

`createSignedUploadUrl()` が有効期限付きのトークンを発行し、`uploadToSignedUrl()` がそれを使ってアップロードします。公式の説明では、これにより「中間サーバーによる検証を必要とせず、クライアントから直接Storageへアップロードするのが容易になる」。

Next.js の Server Action で書くとこうなります。

```ts
// app/actions/upload.ts
"use server";

import { z } from "zod";
import { createClient } from "@/lib/supabase/server";

const requestSchema = z.object({
  // 拡張子の許可リスト。クライアント由来のファイル名は信用しない
  extension: z.enum(["png", "jpg", "jpeg", "webp"]),
  sizeBytes: z.number().int().positive().max(5 * 1024 * 1024),
});

export type UploadTicket =
  | { readonly ok: true; readonly path: string; readonly token: string }
  | { readonly ok: false; readonly reason: "unauthorized" | "invalid" | "failed" };

/**
 * アップロードの「許可」だけを発行する。バイト列はここを通らない。
 * ファイル名はサーバーが決める（パストラバーサルと上書き衝突の芽を断つ）。
 */
export async function createUploadTicket(input: unknown): Promise<UploadTicket> {
  const parsed = requestSchema.safeParse(input);
  if (!parsed.success) return { ok: false, reason: "invalid" };

  const supabase = await createClient();
  const { data: claims } = await supabase.auth.getClaims();
  const userId = claims?.claims.sub;
  if (!userId) return { ok: false, reason: "unauthorized" };

  // 先頭フォルダ = uid。RLS側のポリシーと対になる規約
  const path = `${userId}/${crypto.randomUUID()}.${parsed.data.extension}`;

  const { data, error } = await supabase.storage
    .from("avatars")
    .createSignedUploadUrl(path);

  if (error || !data) return { ok: false, reason: "failed" };
  return { ok: true, path: data.path, token: data.token };
}
```

```tsx
"use client";

import { useState, useTransition } from "react";
import { createClient } from "@/lib/supabase/client";
import { createUploadTicket } from "@/app/actions/upload";

export function AvatarUploader() {
  const [pending, startTransition] = useTransition();
  const [status, setStatus] = useState<"idle" | "uploading" | "done" | "error">("idle");

  async function handleFile(file: File) {
    setStatus("uploading");
    const extension = file.name.split(".").pop()?.toLowerCase() ?? "";
    const ticket = await createUploadTicket({ extension, sizeBytes: file.size });
    if (!ticket.ok) {
      setStatus("error");
      return;
    }

    const supabase = createClient();
    // バイト列はブラウザ → Storage の直送。サーバー関数を通らない
    const { error } = await supabase.storage
      .from("avatars")
      .uploadToSignedUrl(ticket.path, ticket.token, file);

    setStatus(error ? "error" : "done");
  }

  return (
    <div>
      <label htmlFor="avatar" className="block text-sm font-medium">
        プロフィール画像
      </label>
      <input
        id="avatar"
        type="file"
        accept="image/png,image/jpeg,image/webp"
        disabled={pending || status === "uploading"}
        // ネイティブのinputを隠さない。カスタムUIにする場合もフォーカス可能に保つ
        onChange={(e) => {
          const file = e.target.files?.[0];
          if (file) startTransition(() => void handleFile(file));
        }}
      />
      {/* 状態変化をスクリーンリーダーに伝える。role="status" は暗黙で aria-live="polite" */}
      <p role="status" aria-live="polite" className="mt-2 text-sm">
        {status === "uploading" && "アップロード中です"}
        {status === "done" && "アップロードが完了しました"}
        {status === "error" && "アップロードに失敗しました。もう一度お試しください"}
      </p>
    </div>
  );
}
```

このコードの設計判断を明示します。

- **ファイル名はサーバーが決める。** クライアント由来のファイル名をそのままパスにすると、パストラバーサル（`../`）や他ユーザーのファイル上書きの経路になります。UUIDにすれば衝突もありません。
- **拡張子は許可リストで検証する。** 拡張子は嘘をつけますが、少なくとも「何を保存する契約か」をサーバーが宣言できます。実体の検証はバケットの `allowedMimeTypes` に任せます。
- **先頭フォルダをuidにする。** これはRLS側の「自分のフォルダにしか書けない」ポリシーと対になる規約です。サーバーがパスを作るので、規約が破られません。
- **アップロード状態を `role="status"` で伝える。** 進捗表示が視覚だけだと、スクリーンリーダー利用者には「何も起きていない」ように見えます。

### 2-4. S3互換エンドポイント

既存のS3ツールチェーンがあるなら、そのまま使えます。

```javascript
import { S3Client } from '@aws-sdk/client-s3'

const client = new S3Client({
  forcePathStyle: true,
  region: 'project_region',
  endpoint: 'https://project_ref.storage.supabase.co/storage/v1/s3',
  credentials: {
    accessKeyId: 'your_access_key_id',
    secretAccessKey: 'your_secret_access_key',
  }
})
```

ローカル開発では `http://127.0.0.1:54321/storage/v1/s3` と `region: 'local'` を使います。

**注目すべきはセッショントークン方式**です。`accessKeyId` にプロジェクトref、`secretAccessKey` にanonキー、`sessionToken` に有効なJWTアクセストークンを渡すと、公式の言葉ではこうなります。

> セッショントークンで実行されるすべてのS3操作は、認証されたユーザーにスコープされる。Storageスキーマ上のRLSポリシーが尊重される。

つまり **S3プロトコル経由でもRLSが効く**。「S3互換にすると認可が素通しになる」という一般的な懸念が、Supabaseでは当てはまりません。既存のS3ベースの資産を持ち込みつつ、認可はRLSで一元管理できます。

---

## 3. 配信：公開URLと署名URL

### 公開バケット

```ts
const { data } = supabase.storage.from('bucket').getPublicUrl('filePath.jpg')
console.log(data.publicUrl)
```

URLの形は `https://[project_id].supabase.co/storage/v1/object/public/[bucket]/[asset-name]` です。

公開バケットの意味を正確に理解してください。公式の定義では「アセットのURLを持っている人は誰でもファイルにアクセスできる」。**RLSは読み取りに効きません**（アップロードや削除といった他の操作には効きます）。

だから判断はこうなります。

- **公開バケットに置いてよいもの**：ロゴ、OG画像、公開ドキュメント、CDNに強くキャッシュさせたい静的アセット
- **置いてはいけないもの**：請求書、契約書、個人の写真、ユーザーがアップロードした任意のファイル

「URLが推測されないから大丈夫」は成立しません。URLはリファラ、共有、ログ、スクリーンショットから漏れます。

### 署名URL（非公開バケット）

```ts
const { data, error } = await supabase.storage
  .from('bucket')
  .createSignedUrl('private-document.pdf', 3600) // 有効期限を秒で指定
```

公式が明記している重要な性質があります。

> 署名URLは、Authキーの変更に関わらず、有効期限まで有効であり続ける。

つまり**発行してしまった署名URLは、途中で取り消せません**。ユーザーの権限を剥奪しても、既に発行済みのURLは期限まで生きます。したがって設計はこうします。

- **有効期限は用途上必要な最短にする。** 画面表示のためなら数分で十分です。ダウンロードリンクをメールで送るなら数時間。「1年」は事実上の公開です。
- **都度発行する。** 署名URLをDBに保存して使い回すのは、期限管理を放棄することと同じです。

### ダウンロードを強制する

ブラウザに表示させるのではなく保存させたい場合は、URLに `?download` を付けます。ファイル名を指定するなら `?download=customname.jpg` です。

```ts
const { data, error } = await supabase.storage
  .from('avatars')
  .download('avatar1.png', { download: 'my-custom-name.png' })
```

---

## 4. CDN：Smart CDN と 60秒の伝播

Supabase StorageはCDNの背後にあります。Pro以上では **Smart CDN が自動的に有効**です。

通常のCDNとの違いを公式はこう説明します。

> Smart CDNキャッシュを有効にすると、データベース内のアセットのメタデータがエッジに同期される。これにより、アセットが変更または削除されたときにキャッシュが自動的に再検証される。

そして、

> URLで異なるクエリ文字列が使われていても、変更されていないアセットへのリクエストからオリジンサーバーを守ることで、より高いキャッシュヒット率を達成する。

通常のCDNはクエリ文字列が違えば別エントリとして扱いますが、Smart CDNは同一アセットとして扱えます。分析用のクエリパラメータが付いた画像URLでも、オリジンを叩かずに済むということです。

### 60秒の伝播を設計に織り込む

最重要の但し書きがこれです。

> ファイルが更新または削除されたとき、アセットのメタデータが世界中の全データセンターに伝播する必要があるため、**CDNキャッシュの無効化には最大60秒かかることがある**。

「アップロードしたのに古い画像が出る」の正体はこれです。対処は2つあります。

- **上書きしない設計にする（推奨）。** 内容が変わるたびに新しいパスに書きます。ファイル名にコンテンツハッシュやバージョンを含めれば、CDNの無効化を待つ必要がそもそもありません。古いファイルは非同期に削除します。
- **上書きが避けられないなら、60秒の遅延を仕様として受け入れる。** UIに「反映まで最大1分かかります」と明示し、キャッシュバスティング用のクエリを付けるのは最終手段（Smart CDNのヒット率を落とすため）。

### `cacheControl` を意識して設定する

アップロード時の `cacheControl` オプションが、ブラウザ側のキャッシュ期間を決めます。既定は1時間で、公式は「一般的に妥当な既定値」としています。

- **不変アセット（ハッシュ付きファイル名）** → 長く設定する。ヒット率が上がり、転送量コストが直接下がります
- **頻繁に変わるアセット** → 短く。ただし第4章冒頭の通り、そもそも上書きしない設計のほうが良い

---

## 5. 画像変換：オンザフライで小さく配る

Pro以上で画像変換が使えます。オプションはこうです。

| オプション | 値 |
| --- | --- |
| `width` / `height` | 1〜2500 の整数 |
| `resize` | `cover`（既定・アスペクト比を保って切り抜く）/ `contain`（比を保って収める）/ `fill`（比を保たない） |
| `quality` | 1〜100（既定 80） |
| `format` | `origin` で元形式を維持。省略時は自動最適化 |

3つのメソッドで使えます。

```ts
// 公開URL
supabase.storage.from('bucket').getPublicUrl('image.jpg', {
  transform: { width: 500, height: 600 }
})

// 署名URL
supabase.storage.from('bucket').createSignedUrl('image.jpg', 60000, {
  transform: { width: 200, height: 200 }
})

// ダウンロード
supabase.storage.from('bucket').download('image.jpg', {
  transform: { width: 800, height: 300, resize: 'contain' }
})
```

**自動フォーマット最適化**が地味に強力です。公式の説明では「Storageはクライアントがサポートする最良のフォーマットを自動的に見つける」——コード変更なしにWebP等へ切り替わります。元形式を保ちたい場合だけ `format: 'origin'` を渡します。

制限とコストはこうです。

| 項目 | 値 |
| --- | --- |
| プラン | Pro以上 |
| 含まれる変換数 | 月100変換（Pro / Team） |
| 超過 | オリジン画像1,000枚あたり $5 |
| 画像サイズ上限 | 25MB |
| 解像度上限 | 50MP |
| 幅・高さ | 1〜2500 の整数 |

課金単位が「**オリジン画像の枚数**」である点が設計に効きます。同じ元画像から10種類のサイズを作っても、課金対象のオリジンは1枚です。つまり**レスポンシブ画像のために複数サイズを用意することのコスト増は限定的**で、むしろ配信サイズが下がるぶん転送量が減ります。

Next.js の `next/image` と組み合わせる場合、変換をSupabase側で行うか `next/image` 側で行うかは、キャッシュの所在とコストで判断します。Supabase側で変換すればCDNに乗り、Vercel側の画像最適化枠を消費しません。

---

## 6. コスト：転送量が主戦場

| 項目 | Free | Pro |
| --- | --- | --- |
| 保存容量 | 1 GB 込み | 100 GB 込み、以降 $0.0213/GB |
| キャッシュ済み転送 | 5 GB 込み | 250 GB 込み、以降 $0.03/GB |

公式のスケーリングガイドが明快です。

> 画像が転送量の大半を占めるのが一般的。可能な限り小さく保つことで、転送量を削減しアプリケーションのパフォーマンスを向上できる。

効く順に並べるとこうなります。

1. **画像変換で配信サイズを縮める。** 2MBの写真を幅800pxのWebPで配れば、数十KBになります。これだけで転送量が1桁変わります。
2. **`cacheControl` を長くする。** キャッシュヒットは「ブラウザに残る＝転送しない」か「CDNから返す＝安い」のどちらかです。
3. **バケットのファイルサイズ上限を設定する。** 巨大ファイルの流入を入口で止めます。
4. **Smart CDN のヒット率を落とさない。** キャッシュバスティングのクエリを闇雲に付けない。

### リスト取得のパフォーマンス

見落とされがちな性能の穴があります。公式の指摘です。

> 標準の `supabase.storage.list()` メソッドは、フォルダ階層とオブジェクトを同時に取得するため、オブジェクト数が多いと性能が劣化する。

対処は、`storage.objects` に対する自前のPostgres関数を作り、必要なフィルタとページネーションだけを行うことです。フォルダ階層が不要なら、そのほうが速い。

そしてもう1つ、RLSの性能について。

> Storageのテーブルに対してRLSポリシーを作るとき、対象のカラムにインデックスを追加すると参照が高速になる。

ファイル数が数万を超えたあたりから、ポリシー評価のコストが目に見えてきます（[RLSの性能最適化](/blog/supabase-rls-performance-optimization-select-wrap-index-guide)の考え方がそのまま適用できます）。

---

## 7. 本番前チェックリスト

- [ ] 6MBを超えるファイルにTUS再開可能アップロードを使っている（チャンクサイズは6MB固定）
- [ ] 409 Conflict（同一パスへの同時アップロード）をUIで正しく説明している
- [ ] アップロードのパスをサーバーが決めている（クライアントのファイル名をそのまま使わない）
- [ ] 拡張子・MIME型・サイズを、サーバー検証とバケット制限の**両方**で縛っている
- [ ] グローバルのファイルサイズ上限と、バケットごとの上限を設定した
- [ ] 機密ファイルが公開バケットに入っていない（公開バケットは読み取りにRLSが効かない）
- [ ] 署名URLの有効期限が用途上必要な最短である（発行後は取り消せない）
- [ ] 署名URLをDBに保存して使い回していない
- [ ] 上書き前提の設計を避けている（CDN無効化に最大60秒）／受け入れるならUIに明示している
- [ ] 不変アセットに長い `cacheControl` を設定した
- [ ] 画像を変換して配信している（転送量の大半は画像）
- [ ] `storage.objects` に対するRLSポリシーの参照カラムにインデックスがある
- [ ] `list()` の性能が問題になる規模なら、専用のPostgres関数に置き換えた
- [ ] アップロードの状態変化がスクリーンリーダーに伝わる（`role="status"` / `aria-live`）

---

## まとめ：Storageは「置き場所」ではなく「配信経路」

Supabase Storageを本番品質にする設計は、突き詰めるとこう言えます。

**ファイルは保存した瞬間ではなく、配信した瞬間にコストと事故を生む。** だから設計の重心は、アップロードよりも配信側に置きます。

- アップロードは**サーバーを細く**する（許可の発行だけ。バイト列は直送）
- 保存は**上書きしない**（新しいパスに書けばCDNの60秒と戦わずに済む）
- 配信は**小さくする**（画像変換と `cacheControl` が転送量を桁で変える）
- 公開は**意図して選ぶ**（公開バケットは読み取りにRLSが効かないという事実から出発する）

そしてこのすべての土台に、`storage.objects` へのRLSがあります。ファイルもまたテーブルの行である——この一貫したモデルが、Supabaseでファイルを扱うときの最大の強みです。データ・ファイル・リアルタイムを同じ認可の思想で守れるプラットフォームは、そう多くありません。
