File Storage & Uploads

Mini-project: an image gallery with drag-and-drop uploads and secure access.

18 min read·7 parts·intermediate

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.

Avoid
File in a DB column
Bytes stored in a bytea column. Reads are heavy, no CDN, every SELECT drags megabytes across the wire. Backups quietly balloon to gigabytes.
Do
File in storage + row in DB
The bytes live in a bucket, served over a CDN. The DB only stores the storage_path, caption, and user_id. Small, fast, cacheable.
The mental model
DB row = metadata (who, when, caption). Storage object = the actual bytes. The row points at the object by path. Keep them in sync and everything else follows.
Figure 1.1
photos (row)id · uuiduser_id · uuidstorage_path · textcaption · textcreated_at · timestamptzstorage objectpath: u_42/beach.jpgsize: 2.4 MBmime: image/jpeg(actual bytes)points at →
Two homes for two kinds of data. The DB row is the label; the storage object is the box.

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.

Figure 1.2
bucket: photos
├── u_ada/
│ ├── beach.jpg
│ ├── coffee.png
│ └── notebook.webp
├── u_ben/
│ ├── skyline.jpg
│ └── ramen.jpg
└── u_chi/
└── avatar.png
Convention: prefix each object's path with its owner's user_id. That single decision powers the security model in Part 4.
  • 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.

  1. 1Pick

    User drops a file into the dropzone. Client reads its name, size, and MIME type.

  2. 2Upload

    Client sends the bytes directly to the bucket at path `{user_id}/{uuid}.{ext}`.

  3. 3Get path

    Storage confirms and returns the stored path. Nothing else needed from the byte side.

  4. 4Insert row

    Client inserts a photos row with user_id, storage_path, caption, created_at.

Create a private photos bucket. Add a photos table with user_id, storage_path, and caption. On upload, put the file at {user_id}/{uuid}.{ext} and only then insert the DB row.
Why a uuid in the filename?
Two users could both upload 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().

Figure 1.3
requestGET u_ada/beach.jpgstorage policybucket = 'photos'AND (storage.foldername(name))[1]= auth.uid()::textdecisionallow / deny
One policy, four verbs (SELECT / INSERT / UPDATE / DELETE). The path is the source of truth for ownership.
Add storage policies on the photos bucket so a signed-in user can read, upload, and delete only files whose path starts with their own user_id. Also add RLS on the photos table so rows follow the same rule.
Two locks, one key
You need both a storage policy (on the bytes) and a table RLS policy (on the row). Removing either leaves a gap: a policy on rows alone still lets anyone request the file if they guess the path.

Part 5 · Serving images

Once the bytes are stored, the app needs a URL to render <img src=…>. Two flavours:

Do
Public URL
Permanent, cacheable, CDN-served. Anyone with the link sees the file. Good for site imagery. Cheapest and fastest to render.
Do
Signed URL
Temporary token appended to the URL, valid for a set window (e.g. 60s–1h). Required for private buckets. Ask the server to mint one right before rendering.
  • 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.

Figure 1.4
gallery.lovable.app
Drop images here
or click to choose files · JPG, PNG, WebP · max 8 MB
skyline.jpg
62%
Beach, Kuantan
Skyline
Trail
Ramen
Rain
Coffee
Sunset
Notebook
Dropzone above, upload progress, then a grid of tiles. Hover on a tile reveals delete.
  • 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

Figure 1.5
Image doesn't render
├── is there a photos row? → no → insert failed
├── is the storage_path right? → no → upload path bug
├── does the object exist at that path? → no → wrong bucket/path
├── is the bucket public OR did you sign the URL? → no → 400/403
└── does the SELECT policy allow this user? → no → 403
Image not showing? Walk down the tree — one branch answers where you lost it.
  • 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 .jpg that 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 photos bucket" → 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.objects using foldername(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.
Next up
Lesson 14 wires in the fourth pillar of Cloud: AI. You'll add a "summarize this note" button powered by the Lovable AI Gateway — no keys to manage, streaming responses, and the same RLS-guarded data you built in Lessons 11 and 12.