対象: 前の記事で画像変換のURLを試して、「で、更新する人にとって、どう便利になるの?」が気になった方
所要時間: 40〜60分 / 使うもの: ブラウザ、ターミナル、Supabaseのプロジェクト
確認日: 2026年10月(画面や料金は変わることがあります)
前の記事(手順編): https://qiita.com/tomozilla/items/4521efc558885d091743
ビジネス編(やさしい解説・note): https://note.com/tomozilla/n/na726fb1ebab5
サンプルコード一式(GitHub): https://github.com/tomozilla/storage-image-transform-demo
記事の中のコードは日本語コメントです。リポジトリの本体は英語コメントで、日本語コメント版(この記事と同じもの)はja/フォルダーにあります。
前の記事では、画像変換のURLを手で組み立てて、データ量がどれくらい減るかを測りました。でも、実際にお知らせを更新する人は、URLのパラメータなんて組み立てたくありません。
更新する人がやりたいのは、画像を1枚アップロードして、「公開」を押すだけ。そして、見る人には、一覧は軽く、記事は読みやすい大きさで出てほしい。そんな「お知らせCMS」を、管理画面と公開ページの2枚でつくってみました。
0. 先に伝えておきたいこと(正直ベース)
- 画像変換は、ドキュメントでは有料プラン(Pro以上)の機能です。 料金表(2026年10月時点)では、Freeプランは「利用不可」、Pro/Teamは変換元の画像100枚までが枠内で、超えると1000枚あたり5ドル、と書かれています。ところが、私のFreeプランのデモでは、変換が動きました(前の記事のとおり、理由はわかりません)。今後は変わるかもしれません。この動きを当てにせず、最新の情報を確認してください。 私は課金やアップグレードをしていません。
- そのため、公開ページには、変換が使えなかったとき、元の画像にもどす仕組みを入れました(画面にも、「元の画像を表示しています」と出します)。
- 費用の見積もり(計算した目安):Proの料金表を、そのまま読むと、変換する元の画像が、1か月に1,000枚あれば、枠の100枚を引いた900枚が超過です。超過分の請求の丸め方(1000枚単位の切り上げか)は、私は確認していません。ストレージ容量(Freeは1GB)や、通信量(Freeは5GB)も、別に数えられます。
- サンプル画像は、生成した絵で、色が平らな部分が多いです。実際の写真だと、ここまで小さくなりません。
-
未検証:JavaScriptライブラリの
transformオプション、非公開バケットの署名付きURL、Proプランでの動き、実際の写真、スマホでの表示。
1. つくったもの
管理画面(編集者向け)
メールアドレスとパスワードで、編集者がログインします。
ログインしたら、タイトル・本文・カバー画像を入れて、保存します。画像を選んで「保存する」を押すと、こう出ます。
アップロードした画像:406 KB → 一覧では 5.6 KB の画像として配信されます(元の1%)
保存の直後に、一覧で実際に配信される画像の大きさを、画面が取得して見せてくれます。「画像を軽くしておいてください」と、編集者にお願いする必要がありません。
画像以外のファイルを選ぶと、こうなります。
公開ページ(見る人向け)
3つのお知らせが、サムネイルつきで並びます。それぞれの下に、「この画像:4.1 KB(元は414 KB・1%)」のように、実際に届いた大きさを出しています(デモ用の表示です)。
記事を開くと、カバーが大きめで出ます。
同じ406KBの元画像から、**一覧には5.6KB(幅400)、記事には17KB(幅1200・画質75)**が配られました。サイズ違いの画像を、編集者が何枚も作る必要はありません。
2. つくり方
app/index.html 公開ページ(一覧・記事)
app/admin.html 管理画面
sql/app/01_cms.sql 編集者のためのポリシーと、元画像のサイズを覚える列
データベース:編集者のポリシー
「ログインした人は、編集者」とみなして、記事とカバー画像を操作できるようにします。
alter table public.posts add column if not exists cover_bytes int; -- 元画像のサイズ
create policy "editors can read all posts" on public.posts for select to authenticated using (true);
create policy "editors can insert posts" on public.posts for insert to authenticated with check (true);
create policy "editors can update posts" on public.posts for update to authenticated using (true) with check (true);
create policy "editors can delete posts" on public.posts for delete to authenticated using (true);
create policy "editors can upload covers" on storage.objects for insert to authenticated with check (bucket_id = 'cms-images');
create policy "editors can read covers" on storage.objects for select to authenticated using (bucket_id = 'cms-images');
create policy "editors can delete covers" on storage.objects for delete to authenticated using (bucket_id = 'cms-images');
公開済みの記事を誰でも読める、前の記事のポリシー(anyone can read published posts)は、そのままです。
これはデモ用です。 「ログインした人は全員が編集者」になっています。本物のアプリでは、編集者だけを入れるロールや表で絞ってください。
管理画面:アップロードして、記事を保存する
cover_path = `covers/${crypto.randomUUID()}.${ext}` // 名前は、ランダムにする
await supabase.storage.from('cms-images').upload(cover_path, file, { contentType: file.type, upsert: false })
await supabase.from('posts').insert({ title, body, cover_path, cover_bytes: file.size, published })
画像の種類(PNG / JPEG / WebP)と大きさ(10MB)は、保存の前に画面で確認しています。ただし、これは画面での確認だけです(ポリシーでは制限していません)。
公開ページ:用途ごとにURLを変える
// 一覧のサムネイル: 幅400、全体が収まるように縮小
`${base}/render/image/public/cms-images/${path}?width=400&resize=contain`
// 記事のカバー: 幅1200、画質75
`${base}/render/image/public/cms-images/${path}?width=1200&resize=contain&quality=75`
前の記事の「width だけだと切り取られる」を踏まえて、resize=contain を付けています。変換に失敗したら、元の画像(/object/public/...)にもどします。
動かすには、前の記事と同じ要領です。
cd app
cp config.example.js config.js # URL・publishableキー・バケット名を入れる
python3 -m http.server 8765 --bind 127.0.0.1
# 公開ページ: http://127.0.0.1:8765/index.html / 管理画面: .../admin.html
3. つまずいたこと
① 「削除」しても、画像ファイルが消えていなかった(一番大きなつまずき)
管理画面に「削除」ボタンを付けて、お知らせを削除してみました。記事の行は消えて、一覧からもなくなります。ところが、ストレージを見ると、画像ファイルが残っていました。
select name from storage.objects where bucket_id = 'cms-images' order by name;
-- cover-sample.png
-- covers/4533e0fc-….png ← 消したはずのお知らせの画像
-- covers/ab323272-….png ← 同じく
画面はエラーを出していませんでした。supabase.storage.remove() が、**エラーなしで「0件削除」**を返していたのです。
原因は、権限でした。削除するには、削除(DELETE)の権限だけでなく、読む(SELECT)の権限も必要でした。最初は、アップロードと削除のポリシーしか作っていませんでした。なので、対象のファイルが「見えない」ことになり、何も消えなかったようです。
直し方は2つです。
-
読む(SELECT)のポリシーを足す(上の
editors can read covers) -
画面の側で、「ほんとうに消えたか」を確かめる(
remove()の結果の件数を見る)
async function removeCover(path) {
const { data, error } = await supabase.storage.from(BUCKET).remove([path])
return !error && Array.isArray(data) && data.length === 1 // 1件消えたことを確かめる
}
直したあと、アップロード → 削除をもう一度試したら、画像ファイルも消えて、警告も出ませんでした。前に残っていた2つは、ログインしたクライアントから削除しました。
学び:「エラーが出ない」は「成功した」ではない。 特に、権限が絡む削除は、結果を確かめる。残ったファイルは、容量の費用にもなり、見えないところに増えていきます。
② 記事の保存に失敗したら、アップロードした画像が残る
画像を先にアップロードして、そのあと記事を保存しています。記事の保存が失敗すると、画像だけが残ってしまいます。なので、保存に失敗したら、画像を消すようにしました(上の removeCover)。ただ、この失敗の経路を、わざと起こして確かめることは、私はしていません。
③ 公開バケットの画像は、URLを知っていれば誰でも見られる
記事が「下書き」でも、画像のURLを知っている人は、画像を見られます(公開バケットだからです)。下書きの段階の画像を、見せたくない場合は、公開バケットは向きません。署名付きURLと変換の組み合わせが、ドキュメントにありますが、私は試していません。
④ 大きさの表示は、ダウンロードして測っている
一覧の「この画像:4.1KB」は、画像を取得して、その大きさを測っています。デモとしては見やすいのですが、本番の画面には要らない表示です。
4. 実際のCMSにするなら
どれも、私は試していません。
- 編集者のロール:ログインした人全員ではなく、編集者だけ
- 画像のチェック:種類は、ファイルの中身で確かめる(画面での確認だけでは、すり抜けられます)。バケット側にも、大きさと種類の制限を設定できるようです(私は確認していません)
- 残ったファイルの掃除:記事に使われていない画像を、定期的に見つけて消す
- 画面サイズに合わせた配信:スマホには小さく、大きな画面には大きく(複数サイズを用意する)
- 費用の見張り:変換する元の画像の枚数、ストレージ容量、通信量
- 非公開の画像:署名付きURLで、期限つきで見せる
まとめ
- 管理画面から画像を1枚アップロードするだけで、一覧は約5KB、記事は約17KBで配れた(サンプル画像での、私の測定。実際の写真では、ここまで小さくなりません)
- 編集者に「画像を軽くしてね」とお願いする必要がなくなる
- 変換は有料プランの機能とされている。私のFreeでは動いたが、当てにしない。変換が使えないときの元の画像へのもどし先を用意した
- 削除は、権限が足りないと、エラーなしで0件になる。 結果を確かめる
- 公開バケットの画像は、URLを知っていれば誰でも見られる
コードは、上のGitHubにあります。これで、6つのテーマの「手順編」と「アプリ編」が、そろいました。
スクリーンショットは、記事用に作った無料プランのデモ環境で撮影したものです。サンプルの画像は、生成した絵です。





