Milestone 3: PhotosPicker, Supabase Storage, and image URLs
Unit 25 · Project 5, Milestone 3. With auth and a secured feed in place, this milestone lets users add content: pick a photo, upload the bytes to Storage, and create a post row that points at it. Milestone 4 adds pagination, refresh, and likes.
Uploading a photo is three distinct steps that people often blur together: get the bytes from the photo library, put those bytes in storage, and record a row that references them. Keeping them separate — and securing the storage bucket the same way you secured the tables — is what makes this robust.
Picking a photo
PhotosPicker is the modern, privacy-preserving picker: it runs out of process, so the user grants
access to one photo without granting your app the whole library. You get a
PhotosPickerItem, then load its bytes.
import SwiftUI import PhotosUI struct UploadScreen: View { @State private var selection: PhotosPickerItem? @State private var imageData: Data? @State private var caption = "" let model: UploadViewModel var body: some View { Form { PhotosPicker("Choose photo", selection: $selection, matching: .images) if let imageData, let ui = UIImage(data: imageData) { Image(uiImage: ui).resizable().scaledToFit() } TextField("Caption", text: $caption) Button("Post") { Task { await model.post(imageData: imageData, caption: caption) } } .disabled(imageData == nil) } .task(id: selection) { guard let selection else { return } imageData = try? await selection.loadTransferable(type: Data.self) } } }
loadTransferable(type: Data.self) is the async bridge from a picked item to raw bytes. Tying it to
.task(id: selection) reloads whenever the choice changes and cancels a stale load.
Securing the bucket
Create a Storage bucket (say post-images). Storage in Supabase is backed by the same Postgres RLS
machinery, so you write policies here too. The key idea: path per user. Store each user's
uploads under a folder named for their id, and write a policy that only lets a user write inside
their own folder.
-- Anyone signed in can read images (the feed shows everyone's).
create policy "read images" on storage.objects
for select using (bucket_id = 'post-images');
-- You may only upload into a folder named after your own uid.
create policy "upload own images" on storage.objects
for insert with check (
bucket_id = 'post-images'
and (storage.foldername(name))[1] = auth.uid()::text
);(storage.foldername(name))[1] = auth.uid()::text reads: the first path segment of the object name
must equal the caller's user id. So "<uid>/abc.jpg" is allowed for that user and rejected for
anyone else — the same "write only your own" principle as the posts table, applied to files.
A public bucket is not the same as a secured one. Marking a bucket public makes objects
readable by URL, which is fine for a feed — but it says nothing about writes. Without the
insert policy above, any authenticated user could overwrite another user's images. Path-per-user
plus a with check on the folder is what actually protects uploads.
Uploading and recording the post
Now the two backend steps, behind the repository protocol. Upload the bytes, then insert the row that references the stored path. Do them in order: if the upload fails, you never create a dangling row.
import Supabase import Foundation struct NewPost: Encodable { let author_id: UUID let image_path: String let caption: String? } protocol PostRepository: Sendable { func upload(imageData: Data, caption: String?) async throws } struct SupabasePostRepository: PostRepository { let client: SupabaseClient func upload(imageData: Data, caption: String?) async throws { let userID = try await client.auth.session.user.id let path = "\(userID.uuidString)/\(UUID().uuidString).jpg" // 1. Put the bytes in Storage (respects the folder policy above). try await client.storage .from("post-images") .upload(path: path, file: imageData, options: FileOptions(contentType: "image/jpeg")) // 2. Record the row that points at them (respects the posts insert policy). let row = NewPost(author_id: userID, image_path: path, caption: caption) try await client.from("posts").insert(row).execute() } }
Notice the path starts with userID.uuidString — that's what satisfies the storage folder policy.
And author_id is the same user id — that's what satisfies the posts with check policy. Both
writes are authorized by the database against the caller's token; the client can't spoof either.
From stored path to displayable URL
The feed stores image_path (a location), not a URL. Turn it into a URL when rendering. For a
public bucket, ask the client for the public URL; for a private bucket, request a short-lived
signed URL.
// Public bucket: stable URL. let url = try client.storage.from("post-images").getPublicURL(path: post.imagePath) // Private bucket: signed URL that expires. let signed = try await client.storage .from("post-images") .createSignedURL(path: post.imagePath, expiresIn: 3600)
Feed the resulting URL into the actor-based ImageLoader you built in Project 4 — the caching and
request-coalescing carry over unchanged. That's the payoff of building it as a reusable actor: a new
project reuses it as-is.
Store the path, derive the URL. Persisting a full URL in the row is brittle — signed URLs
expire, buckets get renamed, CDNs change. Store the stable image_path and compute a URL at render
time. It's the same "store inputs, derive outputs" rule from the MV lesson, applied to storage.
Checkpoint
Your app should now: pick a photo, preview it, upload it to Storage under your user folder, create a
post row referencing it, and see it appear at the top of the feed with its image loaded through the
Project 4 image cache. Verify in the dashboard that the object landed under <your-uid>/.
Stretch: downscale the image before upload (e.g. cap the longest side at 1600 px) to cut upload
size — resize the UIImage, re-encode to JPEG Data, then upload.
Knowledge check
Q: Why store image_path in the post row instead of a full URL?
Paths are stable; URLs are not (signed URLs expire, buckets and CDNs change). Storing the path and
deriving a URL at render time keeps the row correct forever and lets you switch between public and
signed URLs without a migration.
Q: How does (storage.foldername(name))[1] = auth.uid()::text protect uploads?
It requires the first path segment of every uploaded object to equal the caller's user id, so users
can only write into their own <uid>/ folder. It's the storage equivalent of the "insert only your
own row" table policy — write authorization enforced by the database.