Images without transformations
SupaHost doesn't offer Supabase Storage image transformations. How to serve fast images anyway, by resizing in the browser before upload, resizing in an Edge Function, or serving originals well.
Supabase Cloud can resize, crop and convert images on request through Storage's image transformation feature (the transform option on getPublicUrl(), createSignedUrl() and download(), and the /render/image/ URLs). SupaHost doesn't offer image transformations. Passing transform options won't produce a resized image, so don't build on them.
You can still serve images that load fast. The idea is to create the sizes you need once, when the image is uploaded, instead of on every request. This page covers three ways to do that.
Option 1: resize in the browser before uploading
The simplest approach for user uploads. The browser shrinks the photo before it's sent, which also makes the upload faster and keeps you well under the 50 MB file limit.
// Resize an image File to fit within maxWidth, encoded as JPEG
async function resizeImage(file: File, maxWidth: number, quality = 0.82): Promise<Blob> {
const bitmap = await createImageBitmap(file)
const scale = Math.min(1, maxWidth / bitmap.width)
const width = Math.round(bitmap.width * scale)
const height = Math.round(bitmap.height * scale)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d')!.drawImage(bitmap, 0, 0, width, height)
bitmap.close()
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('Could not encode image'))),
'image/jpeg',
quality
)
})
}
Then upload one file per size you plan to show. Here a recipe app stores a large version and a thumbnail:
async function uploadRecipePhoto(recipeId: string, file: File) {
const id = crypto.randomUUID()
const sizes = { full: 1600, thumb: 400 }
const paths: Record<string, string> = {}
for (const [label, width] of Object.entries(sizes)) {
const blob = await resizeImage(file, width)
const path = `${recipeId}/${id}-${width}.jpg`
const { error } = await supabase.storage.from('recipe-photos').upload(path, blob, {
contentType: 'image/jpeg',
cacheControl: '31536000', // unique path, so it can be cached for a year
})
if (error) throw error
paths[label] = path
}
// Save both paths with the recipe
await supabase.from('recipes')
.update({ photo_full: paths.full, photo_thumb: paths.thumb })
.eq('id', recipeId)
}
Notes:
createImageBitmapapplies the photo's EXIF orientation in current browsers, so phone photos come out the right way up.- Most browsers can also encode WebP (
'image/webp'), which is smaller. Older Safari versions fall back to PNG, so checkblob.typeif you switch. - Resizing on the client means you trust the client. If someone uploads a huge original by calling the API directly, it's still capped by the bucket's
file_size_limitand the 50 MB limit. Set a smallfile_size_limitandallowed_mime_typeson image buckets. See Buckets.
Option 2: resize in an Edge Function
When uploads come from places you don't control (an import, an API, a mobile app you can't update quickly), resize on the server. An Edge Function can download the original, make a thumbnail, and upload it next to the original.
// supabase/functions/make-thumbnail/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'
import { Image } from 'https://deno.land/x/[email protected]/mod.ts'
const admin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
Deno.serve(async (req) => {
// Only let signed-in users resize their own files
const token = (req.headers.get('Authorization') ?? '').replace('Bearer ', '')
const { data: { user } } = await admin.auth.getUser(token)
const { path } = await req.json() // e.g. "<user id>/a1b2-original.jpg"
if (!user || !path.startsWith(`${user.id}/`)) {
return new Response('Forbidden', { status: 403 })
}
const { data: original, error } = await admin.storage.from('photos').download(path)
if (error) return Response.json({ error: error.message }, { status: 400 })
const image = await Image.decode(new Uint8Array(await original.arrayBuffer()))
const thumb = image.resize(400, Image.RESIZE_AUTO)
const jpeg = await thumb.encodeJPEG(80)
const thumbPath = path.replace(/-original\.\w+$/, '-400.jpg')
const { error: upErr } = await admin.storage.from('photos').upload(thumbPath, jpeg, {
contentType: 'image/jpeg',
cacheControl: '31536000',
upsert: true,
})
if (upErr) return Response.json({ error: upErr.message }, { status: 500 })
return Response.json({ thumbPath })
})
Call it from your app after the upload finishes:
await supabase.functions.invoke('make-thumbnail', { body: { path } })
ImageScript is pure JavaScript and WebAssembly, so it runs in the Deno runtime without native dependencies. Its resize is nearest-neighbour, which is fine for thumbnails but less smooth than a browser canvas; if quality matters more, use a fuller library such as ImageMagick's WebAssembly build (npm:@imagemagick/magick-wasm).
Keep the function's limits in mind. Each run gets 150 MB of memory, and a decoded image takes far more memory than its file: a 12-megapixel photo is roughly 48 MB as raw pixels. Large originals or several sizes at once can hit the limit, so resize one size per call for big photos, or combine this with option 1 so originals arrive smaller. See Edge Function limits and Your first Edge Function.
Option 3: serve originals well
If your images are already a sensible size (logos, screenshots, photos you prepared yourself), you may not need resizing at all. A few HTML attributes do a lot:
<img
src="https://app-one.acme.supahost.space/storage/v1/object/public/recipe-photos/r-118/a1b2-1600.jpg"
srcset="
https://app-one.acme.supahost.space/storage/v1/object/public/recipe-photos/r-118/a1b2-400.jpg 400w,
https://app-one.acme.supahost.space/storage/v1/object/public/recipe-photos/r-118/a1b2-1600.jpg 1600w"
sizes="(max-width: 640px) 100vw, 640px"
width="1600"
height="1067"
loading="lazy"
decoding="async"
alt="Lemon tart on a white plate"
/>
widthandheightlet the browser reserve space before the image loads, so the page doesn't jump.srcsetandsizeslet the browser choose the smallest file that fits, if you've stored more than one size.loading="lazy"delays images below the fold until the user scrolls near them.- A long
cacheControlat upload means returning visitors don't download the image again. See Serving and downloading files.
Store the original's width and height in your table when you upload, so you can render these attributes without fetching the image.
If your framework has its own image optimiser (for example the Next.js <Image> component with your project host in images.remotePatterns), it can fetch the original from your public URL and serve resized copies from your app's hosting. Each fetch it makes still counts as egress from your instance.
Which option to pick
| You have | Pick |
|---|---|
| Users uploading photos from your web app | Option 1, plus a small bucket size limit |
| Uploads from many sources, or you need a guaranteed thumbnail | Option 2 |
| A small number of images you control | Option 3 |