0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Supabase Storageの画像変換で、軽量CMSの画像を自動でリサイズ・最適化する

0
Last updated at Posted at 2026-10-26

対象: ブログやお知らせなど、画像つきの記事を更新するアプリをつくっていて、「表示が重いかも」と感じている方
所要時間: 40分前後 / 使うもの: Supabaseのプロジェクト、ブラウザ、ターミナル
続きの記事(このしくみを、実際のWebアプリにしてみた): https://qiita.com/tomozilla/items/dbb5929fb0a9eb93f717
ビジネス編(やさしい解説・note): https://note.com/tomozilla/n/na726fb1ebab5

画像を差し込んだら、アプリの表示がもたつく。経験、ありませんか? 私はあります。スマホで撮った写真をそのままアップロードして、一覧に全部並べたら、小さなサムネイルのために巨大な画像が何枚も届いていたのです。

この記事では、Supabase Storageの**画像変換(Image Transformations)**を使って、元の画像を1枚だけ保管して、表示する大きさに合わせて配るやり方を試します。URLに ?width=400 のようなパラメータを付けるだけ、という手軽さです。

ただし先に言っておくと、実際に試したら、ドキュメントや画面の説明と食い違うところがありました。その点も、そのまま書きます。

同じ画像を、4つの出し方で

0. 先に伝えておきたいこと(正直ベース)

  • ドキュメントには、画像変換は「Proプラン以上」で有効と書かれています。ダッシュボードの設定画面でも、私の無料プロジェクトではスイッチはオフのまま、「Proにアップグレードして画像変換を使えるようにする」と表示されていました。
  • ところが、私の無料プロジェクトでは、変換URLが実際に動きました(理由はわかりません)。何度か試しても再現しました。ただ、仕様や料金は変わる可能性があります。本番で頼る前に、最新のドキュメントと料金を必ず確認してください。私は課金やアップグレードは行っていません。
  • サンプル画像は、記事用に生成した絵です(写真ではありません)。平らな色が多いので、実際の写真より、ずっと小さくなりやすいはずです。数字は「こんな感じ」の目安にしてください。
  • JavaScriptのライブラリ(getPublicUrl の transform)、プライベートバケットの署名付きURL、Next.jsのローダーは試していません。ドキュメントで読んだことは、そう書き分けます。

1. バケット(画像の置き場所)をつくる

Storage → Files → New bucket で、cms-images という名前のバケットをつくります。Public bucket(公開バケット)をオンにしました。

公開バケットをつくる。「公開バケットは保護されません」という注意が出る

公開バケットをオンにすると、こんな注意が出ました。

Public buckets are not protected. Users can read objects in public buckets without any authorization. Row level security (RLS) policies are still required for other operations such as object uploads and deletes.

公開バケットの画像は、URLを知っていれば誰でも見られる、ということです。記事に載せる画像は公開前提なので、今回は公開にしました。社外に見せたくない画像は、公開バケットに置かないでください。

2. 画像をアップロードする

ダッシュボードの Upload files からアップロードします。私が使ったのは、3200×2000ピクセルのサンプル画像(約570KB)です。

アップロードした画像のプレビュー。image/png、569.91KB

つまずきメモ:アップロード直後は、画面の上に「Successfully uploaded 1 file!」というお知らせが出て、画面の一部を隠します。しばらく待ってから画面を撮りました。

3. 元画像を、そのまま配るとどうなる?

公開バケットの画像には、次の形のURLでアクセスできます。

https://<プロジェクトREF>.supabase.co/storage/v1/object/public/cms-images/cover-sample.png

ヘッダーを見てみます(-I はヘッダーだけ取得するオプションです)。

curl -sI "https://<REF>.supabase.co/storage/v1/object/public/cms-images/cover-sample.png"
HTTP/2 200
content-type: image/png
content-length: 583590
cache-control: no-cache
  • 約570KBの画像がそのまま届きます
  • cache-control: no-cache:ブラウザに「毎回確認してください」と伝えている状態でした(これは私の環境での観測です)

小さなサムネイルを10枚並べたら、5.7MBです。これは重いです。

4. 変換URLを使う

画像変換を使うときは、URLの object の部分を render/image に変えて、パラメータを付けます。

https://<REF>.supabase.co/storage/v1/render/image/public/cms-images/cover-sample.png?width=400&resize=contain

動くか確かめる

curl -s -H "Accept: image/webp" -D - -o out.webp \
  "https://<REF>.supabase.co/storage/v1/render/image/public/cms-images/cover-sample.png?width=777&resize=contain&quality=71"
HTTP/2 200
content-type: image/webp
content-length: 10174
cache-control: public, max-age=3600
x-transformations: width:777,resizing_type:fit,quality:71

ちゃんと変換されました(777×486ピクセルのWebP)。わざと誰も指定していない中途半端なサイズ(777)で試したのは、キャッシュで偶然返ってきたのではないことを確かめるためです。x-transformations というヘッダーに、適用された変換が出ています。

また、変換後の画像は cache-control: public, max-age=3600(1時間キャッシュしてよい)で届きました。元画像の no-cache とは違います。

無料プランなのに?

ここで、ダッシュボードの設定を見ます。Storage → Files → Settings です。

「画像変換を有効にする」スイッチはオフで、Proへのアップグレードを促す表示

Enable image transformation はオフのまま(グレーで、触れない状態)。「Upgrade to Pro Plan ... and unlock image transformations」という案内があります。

画面の説明とAPIの動きが食い違っていて、私には理由がわかりません。「画面ではロックされているのに動いた」ことを、事実として書いておきます。この状態がずっと続くとは限りません。

5. パラメータを変えて、全部測ってみる

Accept: image/webp(ブラウザが送るもの)を付けて、いろいろ試しました。元画像は 3200×2000・PNG・583,590バイトです。

指定 結果の大きさ データ量
(元画像そのまま) 3200×2000 PNG 583,590 B
width=400 400×2000 WebP 9,178 B
width=400&resize=contain 400×250 WebP 5,782 B
width=400&resize=contain&quality=50 400×250 WebP 約3.0 KB(下の比較ページで測定)
width=800&resize=contain 800×500 WebP 約13.9 KB(同上)
width=1200&resize=contain&quality=60 1200×750 WebP 15,610 B
width=400&height=400(初期設定のまま) 400×400 WebP 6,718 B
width=400&height=250&resize=fill 400×250 WebP 5,782 B

(Accept: image/webp を付けないと、PNGのまま返ってきました。たとえば width=400 は 70,249バイトのPNG。format=origin を付けても元の形式のままでした。)

つまずき:width=400 だけだと、画像が「切り取られた」

一番驚いたのは、width=400 だけを指定した結果が 400×2000、つまり縦長に切り取られた帯になったことです。「幅400に、縦横比を保って縮小」だと思っていたので、びっくりしました。

ドキュメントには「片方だけ指定すると、リサイズして切り取る(アスペクト比を保つ)」とあり、resize の初期値は cover(枠を埋めるように拡大縮小して、はみ出した部分を切り取る)と書かれていました。resize=contain(全体が収まるように縮小)を付けると、狙いどおり 400×250 になりました。

学び:縦横の比率を保って小さくしたいときは、resize=contain を付ける。同じ要領で height=300 だけだと 3000×300 のような横長の帯になりました(こちらもcover)。

上限は?

ドキュメントには「幅・高さは1〜2500の整数」とありますが、width=3000 を試したところ、エラーにならず 3000×2000 の画像が返ってきました。これも理由はわかりません。上限を当てにしないほうが安全そうです。

画質(quality)

quality は20〜100(初期値80)とドキュメントにあります。width=400&resize=contain で画質を50に下げると、約5.6KB → 約3.0KB になりました(見た目の違いは、下の比較ページで確かめられます)。

6. 目で見て比べる

数字だけでは実感しにくいので、同じ画像を4通りで表示する小さなページをつくりました。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>画像サイズ比較</title>
<style>
  body { font-family: -apple-system, "Hiragino Sans", sans-serif; background:#f6f7f9; margin:0; padding:24px; color:#222; }
  #app { width:1040px; background:#fff; border-radius:16px; padding:22px; box-shadow:0 2px 10px rgba(0,0,0,.08); }
  h1 { font-size:20px; margin:0 0 4px; } p.sub { margin:0 0 16px; color:#666; font-size:14px; }
  #grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
  .card { border:1px solid #e3e6ee; border-radius:12px; padding:10px; background:#fbfcff; }
  .card img { width:100%; aspect-ratio:8/5; object-fit:cover; border-radius:8px; background:#eee; display:block; }
  .t { font-weight:700; font-size:15px; margin:8px 0 2px; } .m { font-size:13px; color:#555; line-height:1.55; }
  .big { font-size:22px; font-weight:800; margin:2px 0; } .bad { color:#dc2626; } .good { color:#16a34a; }
</style>
</head>
<body>
<div id="app">
  <h1>同じ画像を、4つの出し方で</h1>
  <p class="sub">元画像(3200×2000)を、表示する大きさに合わせて配ると、転送量はどう変わる?</p>
  <div id="grid"></div>
</div>
<script>
// __PROJECT_URL__ / __BUCKET__ / __FILE__ は、あなたの環境に置き換えます
const BASE = '__PROJECT_URL__/storage/v1'
const FILE = '__BUCKET__/__FILE__'
const variants = [
  { label: '元画像そのまま',                    url: `${BASE}/object/public/${FILE}` },
  { label: '幅800(contain)',                  url: `${BASE}/render/image/public/${FILE}?width=800&resize=contain` },
  { label: '幅400(contain)',                  url: `${BASE}/render/image/public/${FILE}?width=400&resize=contain` },
  { label: '幅400(contain・画質50)',          url: `${BASE}/render/image/public/${FILE}?width=400&resize=contain&quality=50` },
]
const grid = document.getElementById('grid')
let originalBytes = 0
;(async () => {
  for (const v of variants) {
    const res = await fetch(v.url, { headers: { Accept: 'image/webp,image/png,*/*' } })
    const blob = await res.blob()
    if (!originalBytes) originalBytes = blob.size
    const objUrl = URL.createObjectURL(blob)
    const dims = await new Promise((ok) => { const i = new Image(); i.onload = () => ok(`${i.naturalWidth}×${i.naturalHeight}`); i.src = objUrl })
    const kb = (blob.size / 1024).toFixed(1)
    const pct = Math.round((blob.size / originalBytes) * 100)
    const card = document.createElement('div'); card.className = 'card'
    card.innerHTML = `<img src="${objUrl}"><div class="t">${v.label}</div>
      <div class="big ${pct === 100 ? 'bad' : 'good'}">${kb} KB</div>
      <div class="m">${dims}・${blob.type.replace('image/', '').toUpperCase()}<br>元画像の ${pct}%</div>`
    grid.appendChild(card)
  }
  document.body.dataset.done = '1'
})()
</script>
</body>
</html>

__PROJECT_URL__・__BUCKET__・__FILE__ を自分の環境に置き換えて、python3 -m http.server などで開くと、上の画面が出ます。

同じ画像を、4つの出し方で。見た目はほぼ同じ

  • 元画像:569.9 KB
  • 幅800:13.9 KB(元画像の約2%)
  • 幅400:5.6 KB(約1%)
  • 幅400・画質50:3.0 KB(約1%)

見た目は、この表示サイズではほとんど変わりません。ただし、さきほどお伝えしたとおり、サンプルは平らな色が多い生成画像です。実際の写真だと、ここまで小さくならないはずです。

7. 「軽量CMS」の形にする

画像変換の便利さは、元画像を1枚だけ保管して、使う場所ごとにサイズを変えられるところです。記事のテーブルには、元画像のパスだけを持たせます。

create table public.posts (
  id bigint primary key generated always as identity,
  title text not null,
  body text not null,
  cover_path text,          -- 例: 'cover-sample.png'
  published boolean not null default false,
  created_at timestamptz not null default now()
);
alter table public.posts enable row level security;

-- 公開済みの記事だけ、誰でも読める
create policy "anyone can read published posts" on public.posts
  for select to anon, authenticated
  using (published);

(SQL Editorが「RLSを有効にしないままテーブルをつくろうとしています」と警告したので、Run and enable RLS を選びました。)

公開された記事だけが見えることを、anon(ログインしていない人)で確かめました。

公開済みの記事(1件)だけが見える

表示するときは、一覧では小さく、記事ページでは中くらいに、と使う場所ごとにURLを組み立てます。

一覧のサムネイル: .../render/image/public/cms-images/{cover_path}?width=400&resize=contain
記事のカバー  : .../render/image/public/cms-images/{cover_path}?width=1200&resize=contain&quality=75

ドキュメントによると、JavaScriptのライブラリでは getPublicUrl('cover-sample.png', { transform: { width: 400, resize: 'contain' } }) のように書けるそうです(私は未検証)。更新する人は元画像を1回アップロードするだけで、サイズ違いを用意する手間がありません。

気をつけたいこと

  • プラン・料金は必ず最新を確認:ドキュメントには、Pro・Teamで元画像100枚までが枠内、超えると1,000枚あたり5ドルと書かれていました(私が読んだ時点の内容です)。「変換した画像の枚数」ではなく「変換元の画像の枚数」で数える、とも書かれています。
  • 元画像は消さない:変換は元画像から作られます。
  • resize=contain を忘れない:縦横比を保ちたいなら、必須です。
  • 公開バケットの画像は誰でも見られる:非公開にしたい画像には、署名付きURL+変換の組み合わせ(ドキュメントにあります)を検討します(未検証)。
  • 対応形式と上限:ドキュメントには、入力はPNG・JPEG・WebP・AVIFなど、サイズは25MB・50メガピクセルまで、と書かれていました。出力はWebPが自動で選ばれるとあります(私の測定では、ブラウザ相当の Accept: image/webp を付けたときにWebPでした)。

まとめ

  • 画像変換は、URLの render/image と ?width=...&resize=contain で使える(私の環境では、無料プランでも動いた。ただし画面・ドキュメントの説明とは食い違っていたので、必ず最新情報を確認)
  • 同じ画像でも、表示サイズに合わせると転送量がずっと小さくなる(私の生成画像では、元の1〜2%)
  • width だけだと切り取られる。比率を保つなら resize=contain
  • CMSのテーブルには、元画像のパスだけを持たせる

次回、また別のテーマでお会いしましょう。


スクリーンショットは、記事用に作った無料プランのデモ環境で撮影したものです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?