Lessons 11 and 12 gave you rows and relationships. Rows are tiny — a note, a task, a tag. But most apps also carry heavy things: photos, PDFs, avatars, audio clips. This lesson builds a personal image gallery with drag-and-drop uploads and per-user access. The bouncer from Lesson 11 is back, this time checking the coat room.
Part 1 · Why storage is its own thing
You could technically stuff a photo into a database column as raw bytes. Please don't. Databases are optimised for small structured rows you scan, filter, and join. A 4 MB image sitting inside a row makes every query slower, backups bloated, and caching pointless. Files belong in object storage — a separate service tuned for exactly this shape of data.
bytea column. Reads are heavy, no CDN, every SELECT drags megabytes across the wire. Backups quietly balloon to gigabytes.storage_path, caption, and user_id. Small, fast, cacheable.Part 2 · Buckets & objects
Storage has two nouns: buckets and objects. A bucket is a named folder tree — you typically make one per feature (photos,avatars, invoices). An object is a single file inside a bucket, addressed by a path. Paths are just strings with slashes; the folders they suggest are virtual, not real directories.
- Public bucket — every object has a permanent URL anyone with the link can hit. Great for site assets, hero images, blog covers.
- Private bucket — objects are only reachable via a signed URL your server hands out, or via a policy that lets the caller read them. Use this for user uploads by default.
- You can change public/private later, but the URLs change with it — pick before you share links widely.
Part 3 · The upload flow
A photo takes two writes to land safely: the bytes go to the bucket, and a row goes into photos pointing at them. Do storage first — if the DB insert fails, you can retry; if you insert first and the upload fails, you have a row with no image.
- 1Pick
User drops a file into the dropzone. Client reads its name, size, and MIME type.
- 2Upload
Client sends the bytes directly to the bucket at path `{user_id}/{uuid}.{ext}`.
- 3Get path
Storage confirms and returns the stored path. Nothing else needed from the byte side.
- 4Insert row
Client inserts a photos row with user_id, storage_path, caption, created_at.
Create a privatephotosbucket. Add aphotostable withuser_id,storage_path, andcaption. On upload, put the file at{user_id}/{uuid}.{ext}and only then insert the DB row.
selfie.jpg. Even inside one user's folder, they might upload it twice. Prefixing (or replacing) the filename with a UUID guarantees no collisions and no accidental overwrites.Part 4 · Per-user access
Storage has its own policy table — same idea as RLS, applied to objects instead of rows. The trick is that a policy can read the path itself. Since we prefixed every path with the owner's user_id, the policy can just compare the first segment to auth.uid().
Add storage policies on thephotosbucket so a signed-in user can read, upload, and delete only files whose path starts with their ownuser_id. Also add RLS on thephotostable so rows follow the same rule.
Part 5 · Serving images
Once the bytes are stored, the app needs a URL to render <img src=…>. Two flavours:
- User uploads (photos, avatars, receipts) → private bucket + signed URL. Default choice.
- Marketing assets, blog covers, logos → public bucket + public URL. Fine to leak by design.
- Mixing them is normal — one app can have three buckets with different rules.
Part 6 · UI patterns
The gallery is three pieces on one screen: a dropzone, a progress row while the upload is in flight, and a responsive grid of tiles below.
- Show progress. A silent upload feels broken past ~1 second.
- Validate on the client (size, MIME) before you hit the network — good errors are cheap here.
- Optimistic tile: render a blurred placeholder as soon as upload starts, swap in the real image when the row lands.
- Delete needs a confirm — files disappearing on a single click never ends well.
Part 7 · Traps & debugging
- Orphaned files — you deleted a row but not the object. Fix: delete the object first, then the row (or use a cascading server function).
- MIME mismatch — an
.jpgthat is really a PDF renders as broken. Validate MIME server-side if it matters. - Size caps — set a max upload size in the client and mirror it in the bucket settings. A 200 MB photo will time out on mobile.
- Signed URL expired — your grid re-renders after an hour and everything 403s. Either regenerate on mount or use a longer TTL for gallery pages.
- CORS — direct-to-bucket uploads require the bucket to allow your app's origin. Default is usually fine; custom domains need a check.
Storage cheat sheet
Quick prompts
- "Create a private
photosbucket" → one call, no SQL. - "Upload to
{user_id}/{uuid}.{ext}, then insert a photos row" → the two-step write, in order. - "Only allow users to read their own files" → storage policy on
storage.objectsusingfoldername(name)[1] = auth.uid()::text. - "Serve gallery images via signed URLs valid for 1 hour" → private bucket + short-lived tokens.
Public vs private — quick pick
- Public: marketing images, logos, blog covers, anything you'd embed in a shared preview.
- Private: user uploads, receipts, medical images, avatars if profiles are private.
- When in doubt, go private and sign URLs — you can always relax later.
Debug checklist
- Broken image → check the storage_path in the row against the actual object in the bucket.
- Upload succeeds but nothing appears → the row insert failed silently; check RLS on the photos table.
- 403 on read → storage SELECT policy is missing or the bucket is private without a signed URL.
- Delete looks fine but the file is still there → you deleted the row, not the object.
Sign up with my invite link and Lovable will drop 10 bonus credits into your account — enough to try everything in this course.
Claim 10 credits