How to add image upload to your app — 5 steps and one trap

Your first instinct with an avatar upload is to stuff the image into the database. Everything else lives there — name, email — so why not the photo?
Don't. Here's why. An image in a database becomes text about 1.3× longer than the original. A 2 MB avatar turns into a 2.7 MB string in every row of the table. A hundred users later, "give me the user list" starts hauling hundreds of megabytes to display ten names.
The right setup is different, and it's simpler: the file goes to a warehouse, the database gets only a link. Let's walk it.
Step 1. Get a warehouse for files
The warehouse for images is called storage (or a "bucket"). It's not a database — it's a separate file store that hands files out over a URL.
You don't need your own server; every backend service has storage built in. In Supabase it's Storage → New bucket. Give it an obvious name, like avatars.
One decision here — public or private bucket:
- Public — anyone with the link can open it. Fine for avatars, covers, product photos.
- Private — the file is served through a temporary link. Needed for documents and anything that shouldn't leak.
Start public if everyone sees the images anyway. The examples below assume public.
Step 2. Put an input in the form
The boring part, but it has a couple of nuances:
<input type="file" accept="image/*" id="avatar" />
accept="image/*" filters the file picker — the user won't see their PDFs. On a phone, the same attribute also offers the camera.
Remember the key thing: accept is a hint, not protection. A file with any content can still be handed over. Hence step 3.
Step 3. Check the file before sending
Two checks that will save your nerves:
const file = event.target.files[0]
if (!file) return
const MAX_MB = 5
if (file.size > MAX_MB * 1024 * 1024) {
alert(`File is larger than ${MAX_MB} MB — pick a smaller one`)
return
}
if (!file.type.startsWith('image/')) {
alert('Needs to be an image')
return
}
Why it matters: without a limit, someone uploads a 200 MB video, you pay for the traffic, and the page takes a minute to load.
And an honest warning: a browser check is convenience, not security. It helps a normal person. Anyone who wants around it will open the console and go around it. The real limit lives on the service side, in the bucket settings — that's where you set both the max file size and the allowed types. Set them.
Step 4. Upload the file and get a URL
The upload itself is two lines:
const fileExt = file.name.split('.').pop()
const filePath = `${userId}-${Date.now()}.${fileExt}`
const { error } = await supabase.storage
.from('avatars')
.upload(filePath, file)
if (error) throw error
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(filePath)
console.log(data.publicUrl)
Notice filePath. We invent the filename rather than taking file.name. Three reasons: user filenames come with spaces and non-Latin characters, two people will upload different photo.jpg files and overwrite each other, and a filename with a path like ../../secret is a security hole.
One more detail: by default upload won't overwrite an existing file — it fails with an error. That's protection against accidental clobbering. If you actually want replacement (an avatar being changed), add { upsert: true }:
await supabase.storage.from('avatars').upload(filePath, file, { upsert: true })
Step 5. Save the link in the database
Now the database steps in — and gets one short string:
await supabase
.from('profiles')
.update({ avatar_url: data.publicUrl })
.eq('id', userId)
Done. The table holds 80 characters of text instead of megabytes. The user list flies again, and the browser fetches images separately and in parallel — and caches them, because they're served by a CDN, not by your database.
What you end up with
A working loop: someone picks a file → the browser rejects oversized ones → the file lands in the bucket under a unique name → the profile's link updates → the avatar shows up on any device, because it doesn't live on your laptop.
Two free upgrades, now that the plumbing is right.
Preview before upload — show the picked file immediately, without waiting for the send:
const preview = URL.createObjectURL(file)
imgElement.src = preview
Resize on the fly — don't ship a 4000×3000 avatar into a 40-pixel circle:
supabase.storage.from('avatars').getPublicUrl(filePath, {
transform: { width: 200, height: 200 },
})
One line, and a few kilobytes fly instead of three megabytes.
Where do the storage keys go?
Only the public key lives in the browser — that's what public means. The service key, which bypasses every access rule, must never go into client code: it ends up in the bundle and belongs to anyone who opens the Network tab. More on this: how to store API keys safely.
What happens to the old image on replacement?
Nothing, unless you delete it yourself with remove([oldPath]). Upload three avatars and the warehouse holds three, and you pay for three. Either clean up the old one after a successful replacement, or reuse the same path with upsert: true so the file is simply overwritten.
Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.





