画像変換を使った「お知らせCMS」をつくってみた:管理画面から画像を上げるだけで、一覧も記事も軽くなる

対象: 前の記事で画像変換のURLを試して、「で、更新する人にとって、どう便利になるの?」が気になった方
所要時間: 40〜60分 / 使うもの: ブラウザ、ターミナル、Supabaseのプロジェクト
確認日: 2026年10月(画面や料金は変わることがあります)
前の記事(手順編): Supabase Storageの画像変換で、軽量CMSの画像を自動でリサイズ・最適化する
ビジネス編(やさしい解説): AIでアプリをつくる人が知っておきたい「画像の自動最適化」:表示速度と更新の手間を減らす考え方
サンプルコード一式(GitHub): GitHub
記事の中のコードは日本語コメントです。リポジトリの本体は英語コメントで、日本語コメント版(この記事と同じもの)は 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%)

保存の直後に、一覧で実際に配信される画像の大きさを、画面が取得して見せてくれます。「画像を軽くしておいてください」と、編集者にお願いする必要がありません。

画像以外のファイルを選ぶと、こうなります。

画像ではないファイルは、保存前に断る

公開ページ(見る人向け)

お知らせの一覧。サムネイルは、元の約1%

3つのお知らせが、サムネイルつきで並びます。それぞれの下に、「この画像:4.1 KB(元は414 KB・1%)」のように、実際に届いた大きさを出しています(デモ用の表示です)。

記事を開くと、カバーが大きめで出ます。

記事ページ。カバーは幅1200・画質75で、17KB

同じ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つです。

  1. 読む(SELECT)のポリシーを足す(上の editors can read covers)
  2. 画面の側で、「ほんとうに消えたか」を確かめる(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つのテーマの「手順編」と「アプリ編」が、そろいました。

Leave a Reply

Your email address will not be published. Required fields are marked *