# Serving images from Supabase Storage without shipping 4 MB avatars

> On-the-fly transformation resizes at read time, but it is metered and it fights your cache. When to transform, when to resize on upload, and how signed URLs complicate both.

Your profile page loads a 48-pixel avatar. The network tab says 4.2 MB. The user
uploaded a photo straight from their phone, you stored it as-is, and the browser
is downloading twelve megapixels to draw a circle the size of a fingernail.

Multiply by every avatar in a list view and the page is unusable on mobile,
while your storage egress bill grows for bytes nobody can perceive.

## Three ways to fix it, and how to choose

**1. Transform on read.** Ask storage for a resized version at request time.
Zero upload complexity, works for images already stored, and it is metered.

**2. Resize on upload.** Generate the sizes you need once, store them, serve
them directly. No per-read cost, no vendor lock-in, more code and a job to run.

**3. Do neither, and constrain the input.** For avatars, crop in the browser
before upload. The 4 MB never exists.

Most apps want 3 for avatars and 1 for everything else. Reach for 2 when you
serve a lot of images to a lot of people and the transformation meter starts
showing up on the invoice.

## Transform on read

```ts
import { getSignedDownloadUrl } from "@/lib/storage";

const avatarUrl = await getSignedDownloadUrl(user.avatarKey, {
  expiresIn: 900,
  transform: { width: 128, height: 128, resize: "cover", quality: 75 },
});
```

Supabase resizes on the way out, caches the result at its CDN, and serves WebP
or AVIF when the browser accepts it. The 4 MB photo becomes about 8 KB.

Things worth knowing before you sprinkle this everywhere:

- **It is billed per origin image per month**, not per request. A hundred sizes
  of one image count once; one size of a hundred thousand images counts a
  hundred thousand times. Cheap for avatars, surprising for a gallery.
- **It is a paid-plan feature.** On the free tier the parameters are ignored and
  you silently serve the original, which is exactly the bug you were fixing, so
  check the byte count rather than assuming.
- **`resize` matters.** `cover` fills and crops (what you want for avatars),
  `contain` fits inside, `fill` distorts. Pick deliberately.
- **Ask for the size you render, times the device pixel ratio.** A 128 px avatar
  on a 2× screen wants 256 px. Requesting one size and scaling in CSS wastes
  either bytes or sharpness.

## Signed URLs fight your cache

This is the part that catches people.

A signed URL contains a token and an expiry. Two consequences:

- **The URL changes on every render.** Different URL, different cache key: the
  browser and the CDN both re-fetch an image they already have. A list of fifty
  avatars re-downloads all fifty on every page load.
- **A cached page outlives its URLs.** Cache a server-rendered page for an hour
  with fifteen-minute URLs in it and, forty-five minutes in, everyone gets
  broken images.

Ways out, in order of preference:

**Serve genuinely public images from a public bucket.** An avatar that appears
next to a public comment is not private. Put it in a separate public bucket, get
a stable URL, and let the CDN and the browser cache it properly. This is the
right answer more often than people expect.

**Match the cache lifetime to the URL lifetime.** If a page holds signed URLs,
its own cache must be shorter than the shortest URL in it. Long-lived URLs are
not the fix: a URL that lives a week is a week-long unauthenticated grant.

**Sign per request, render dynamically.** Correct, and it means the page cannot
be static. Fine for a dashboard, expensive for a marketing page.

**Proxy through your own route.** A stable app URL that checks the session and
redirects to a fresh signed URL. You get caching under your control and a
permission check per request, at the cost of a hop.

## Resize on upload

When reads are heavy and predictable, do the work once:

```ts
import sharp from "sharp";
import { putObject } from "@/lib/storage";

export async function generateThumbnails(key: string, original: Buffer) {
  for (const width of [128, 512]) {
    const resized = await sharp(original).resize(width, width, { fit: "cover" }).webp({ quality: 78 }).toBuffer();
    await putObject({
      key: key.replace(/(\.[a-z0-9]+)$/i, `-${width}$1`).replace(/\.[a-z0-9]+$/i, ".webp"),
      body: resized,
      contentType: "image/webp",
      cacheControl: "public, max-age=31536000, immutable",
    });
  }
}
```

Run it from a background job, not from the request that finished the upload:
`sharp` on a 12 megapixel image takes real CPU and the user is waiting.

Store the derived keys alongside the original so a render never has to guess
which sizes exist.

## Next.js `<Image>` and remote sources

`next/image` optimises remote images, but every remote host must be allowed in
`next.config.ts`, and its optimiser is billed per source image on Vercel, so
you can end up paying two optimisers to do one job. Either:

- let Supabase transform and render a plain `<img>` with explicit `width` and
  `height` (no layout shift, no second optimiser); or
- store the original and let `next/image` do everything, with no transform
  parameters.

Doing both is a common accident. Check the network tab: if the URL contains both
`/render/image` and `/_next/image`, you are paying twice.

## What to check

- Open a page with avatars and read the transferred size. A 128 px avatar should
  be single-digit kilobytes.
- Reload. If every image re-downloads, your URLs are changing per render.
- Look at the response `content-type`. Modern browsers should get WebP or AVIF.
- On a paid plan, confirm the transformation actually applied: the free tier
  ignores the parameters and serves the original.
- Check what `cache-control` your images come back with, and whether that is
  compatible with how long you cache the page that references them.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
