DEV Community

Tomo
Tomo

Posted on AI-assisted

Serve One Image at Many Sizes With Supabase Storage Image Transformations: Measurements, Surprises, and a Small CMS

I wanted to know what Supabase Storage image transformations actually change for a content site: how many bytes, and what goes wrong. So I stored one image, requested it a dozen ways, measured every response, and then built a tiny announcements CMS that uses transformed URLs for its list and article pages. The measurements are below, along with four surprises: a crop I did not ask for, a format that depends on the request, a fake image that got through my checks, and a feature that the docs say needs Pro but worked on my Free project.

AI disclosure: I used Claude to help draft this post. I ran every step in my own project, checked the results, and edited the text. The mistakes are mine.

Repo: https://github.com/tomozilla/storage-image-transform-demo-en
Plain-language companion (no code, no product names): https://medium.com/p/66bc2dd1fffd

The public announcements list: two cards with cover images, each showing the size served, 4.1 KB and 5.6 KB, against originals of 414 KB and 570 KB.

What you will do

  1. Create a public bucket, upload one image, and fetch it as is and through transformation URLs.
  2. Measure bytes and dimensions for width, resize mode, quality and format.
  3. Build a small CMS (admin page and public page) around it and see what breaks.

Before you start: cost

  • The docs say image transformation is for Pro and above ("Image Resizing is currently enabled for Pro Plan and above"). Pricing from the docs (checked 2026-10-22): Free is unavailable; Pro and Team include 100 origin images, then USD 5 per 1,000 origin images. An "origin image" is a distinct image transformed in a billing period, however many sizes you request.
  • On my Free-plan project the transformation URLs worked. In Storage settings, "Enable image transformation" showed as off:

Storage settings: the

I do not know why the URLs worked anyway. It may change or become billable, so check the current docs and pricing before you depend on it. I did not upgrade or pay for anything, and I have not seen a bill for this feature.

  • Not verified: the JavaScript SDK transform option, signed URLs for private buckets, the Next.js loader, real photographs.

1. A bucket and one image

Storage, Files, New bucket. I made cms-images public:

The Create file bucket dialog with Public bucket on and the warning

The warning matters: anyone who knows a URL in a public bucket can read the file. That is fine for article images and wrong for anything private.

I uploaded one generated sample image (3200x2000, a flat-color illustration, not a photo):

The file preview: cover-sample.png, image/png, 569.91 KB.

2. Transformation URLs

A public file is at /storage/v1/object/public/<bucket>/<file>. Put render/image in place of object and add parameters:

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

The measuring script fetches the image several ways and prints status, content type and bytes:

show() {  # show <label> <URL>
  local meta; meta=$(curl -s -H "Accept: image/webp" -o "$out" -w '%{http_code} %{content_type} %{size_download}' "$2")
  printf '%-40s %s\n' "$1" "$meta"
}
Enter fullscreen mode Exit fullscreen mode
original, as is                          200 image/png 583590
width=400 (default: cover)               200 image/webp 9178
width=400&resize=contain                 200 image/webp 5782
width=400&contain&quality=50             200 image/webp 3040
width=800&resize=contain                 200 image/webp 14276
Enter fullscreen mode Exit fullscreen mode

To see it, I also made a page that fetches four variants and draws them with their size:

The same sample image shown four ways: original 569.9 KB, width 800 contain 13.9 KB, width 400 contain 5.6 KB, width 400 quality 50 3.0 KB.

At 400 pixels wide it is 5.6 KB instead of 570 KB, about 1%. Caveat: my picture has large flat areas and compresses far better than a real photo.

3. What the parameters really do

I downloaded each variant and read its dimensions:

Request Bytes Result
original 583,590 PNG, 3200x2000
width=400 9,178 WebP, 400x2000
width=400&resize=contain 5,782 WebP, 400x250
width=400&height=400 6,718 WebP, 400x400 (cropped)
width=400&height=400&resize=contain 5,782 WebP, 400x250
width=400&height=400&resize=fill 7,622 WebP, 400x400 (stretched)

Width alone crops. I asked for 400 pixels wide and got a 400x2000 strip. The default mode (cover) fills the requested box and cuts the rest, and with only a width the box is as tall as the original. Add resize=contain to keep the whole picture.

Quality at width=1200&resize=contain:

quality 20 50 75 80 (default) 100
bytes 8,682 13,674 18,900 23,284 82,846

The docs give a range of 20 to 100 and a default of 80; the 80 and no-parameter results were identical (23,284 bytes). Going from 80 to 100 more than tripled the size.

The format depends on the request. Storage returns WebP when the client says it accepts it:

width=400 contain, Accept: image/webp      200 image/webp 5782
width=400 contain, Accept: image/png       200 image/png  67112
width=400 contain, no special Accept       200 image/png  67112
width=400 contain, format=origin           200 image/png  67112
Enter fullscreen mode Exit fullscreen mode

Browsers send that header, so visitors get WebP. If you measure with a plain tool and forget it, you get a number about 12 times too big.

Limits. The docs say width and height must be between 1 and 2500. I asked for more:

width=2500 contain    200  55,252 B
width=5000 contain    200  69,676 B  (3000x1875)
width=0               200  69,676 B
missing file          400  {"statusCode":...} 88 B
Enter fullscreen mode Exit fullscreen mode

No error for 5000 or 0, and 5000 came back 3000x1875, not 5000 or 3200. I do not know the rule; I only know the request did not fail.

4. A small CMS around it

Posts store only the path of the original. Sizes are chosen later, in the URL.

The admin page uploads a cover to the bucket and saves the path in the posts table; the public page requests sized versions.

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

-- Anyone can read published posts only
create policy "anyone can read published posts" on public.posts
  for select to anon, authenticated
  using (published);
Enter fullscreen mode Exit fullscreen mode

Two sample posts:

A visitor with no login sees the published post only (select ... from public.posts as anon returned 1 row). For the editors, the CMS adds policies on posts and on storage.objects (see sql/app/01_cms.sql), and I created an editor user:

Eight policies: one public read policy for published posts, and seven editor policies on posts and on cover files.

The public page asks for a sized version and falls back to the original if that fails:

async function loadImage(path, query) {
  const headers = { Accept: 'image/webp,image/png,image/jpeg,*/*' }
  try {
    const r = await fetch(variantUrl(path, query), { headers })
    if (r.ok) { const b = await r.blob(); return { src: URL.createObjectURL(b), bytes: b.size, fallback: false } }
  } catch (_) { /* fall back to the original below */ }
  const r2 = await fetch(originalUrl(path)); const b2 = await r2.blob()
  return { src: URL.createObjectURL(b2), bytes: b2.size, fallback: true }
}
// list thumbnails:  loadImage(p.cover_path, 'width=400&resize=contain')
// article cover:    loadImage(p.cover_path, 'width=1200&resize=contain&quality=75')
Enter fullscreen mode Exit fullscreen mode

I logged into the admin page as the editor and uploaded a second image:

Admin page after saving: the list serves it as a 4.1 KB image (1% of the original)"."/>

The article page serves the cover at width 1200 and quality 75:

The article page: the title, the text, and

5. What went wrong

A fake image got through. The admin page checked file.type, but the browser derives that from the file name. I made a 6-byte text file named fake.png and uploaded it. It was accepted, saved and published. The transformation then failed with HTTP 500 and the public page showed a broken image with a message that blames the wrong thing:

Public list: a broken image under

$ curl ".../render/image/public/cms-images/covers/<id>.png?width=400&resize=contain"
{"statusCode":"500","error":"InternalError","message":"Internal error"}
$ curl ".../object/public/cms-images/covers/<id>.png"      -> 200 image/png, 6 bytes
Enter fullscreen mode Exit fullscreen mode

The fix is to read the first bytes (PNG starts 89 50 4E 47, JPEG FF D8 FF, WebP is RIFF....WEBP) and give a clear message:

async function sniffType(file) {
  const b = new Uint8Array(await file.slice(0, 12).arrayBuffer())
  const at = (i, s) => [...s].every((c, k) => b[i + k] === c.charCodeAt(0))
  if (b[0] === 0x89 && at(1, 'PNG')) return 'image/png'
  if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
  if (at(0, 'RIFF') && at(8, 'WEBP')) return 'image/webp'
  return null
}
Enter fullscreen mode Exit fullscreen mode

After the fix:

This is still only a browser-side check. Anyone can send files to the bucket without my page. For real use, set allowed MIME types on the bucket and validate on the server.

Deleting a cover needs a SELECT policy too. The SQL comment says so, and I checked it. I dropped the editors can read covers policy, uploaded a cover, and deleted the post. The row went away. The image stayed in the bucket, with no error anywhere:

Storage objects after deleting a post without the SELECT policy: the deleted post's file is still listed.

I restored the policy and deleted the orphan by hand.

That SELECT policy trips an advisor. The dashboard now shows:

Warning:

Both statements are true: deletes need it, and it lets any logged-in user list every file.

Every logged-in user is an editor. The demo policy is to authenticated, with no role check. I noticed because the browser still held a login for demo-acme from my RLS demo. It opened the admin page and listed every post, with Delete buttons:

The admin page logged in as demo-acme@example.com, showing the announcements list with Delete buttons.

Use a role or an editors table before you use this for real.

Security

  • config.js is git-ignored; only the publishable key belongs there.
  • Files in a public bucket can be fetched by anyone who knows the URL, including the images of draft posts. Do not use a public bucket for private images.
  • Editors: any logged-in user, in this demo. The image-type check runs in the browser only.

Cleanup

Run sql/app/99_cleanup.sql, delete the files and the bucket in the dashboard, and delete the editor user.

Recap

  • One 570 KB original became 3 to 14 KB with width 400 or 800, and 5.6 KB at 400 pixels wide, because Storage serves WebP to clients that accept it.
  • A width alone crops (400x2000 for me): use resize=contain to keep the whole picture. Without the WebP hint, the same request was 12 times bigger.
  • Check uploads by content, on the server. My page-only check let a fake image through.
  • Deleting needs SELECT, and SELECT lets users list the bucket.
  • The docs say Pro; my Free project served the transformed URLs anyway. I do not know why, and it may change.
  • All numbers: one generated image, one project, one day.

The code is in the repo: https://github.com/tomozilla/storage-image-transform-demo-en. The plain-language version: https://medium.com/p/66bc2dd1fffd.

Top comments (0)