Skip to content

Combining REST, Realtime, and Storage

A real feature rarely touches only one Supabase service — uploading a photo, saving a row that references it, and having other clients see it appear live is one flow that composes Storage, the RLS-protected REST API, and Realtime, and each piece is exactly what you already learned in an earlier lesson.

This is the same supabase.storage.from(bucket).upload() call from the Storage half of the Realtime and Storage module. It sends the file’s bytes to a bucket and, on success, returns the path the file was stored at:

const file = fileInput.files[0];
const filePath = `${userId}/${crypto.randomUUID()}-${file.name}`;
const { data: uploadData, error: uploadError } = await supabase.storage
.from('photos')
.upload(filePath, file);
if (uploadError) {
console.error('upload failed:', uploadError.message);
return;
}

Checking error here matters as much as it did in the previous lesson: a network blip or a Storage policy rejecting the upload both surface through uploadError, not a thrown exception, and skipping this check means silently trying to insert a row that points at a file that was never actually saved.

Step 2: save a row that references it, protected by RLS

Section titled “Step 2: save a row that references it, protected by RLS”

With the file safely stored, the app saves a posts row pointing at it. This is a plain .insert() call, but it only succeeds because of the row level security policy from the Auth module — a policy that lets a user insert a row only when user_id matches auth.uid(). Nothing new is required on the client to get that protection; it is enforced automatically by Postgres for every request the client sends:

const { data: post, error: insertError } = await supabase
.from('posts')
.insert({
user_id: userId,
photo_path: uploadData.path,
})
.select()
.single();
if (insertError) {
console.error('insert failed:', insertError.message);
return;
}

If userId did not match the signed-in user’s auth.uid(), RLS would reject the insert and insertError would be populated — the same { data, error } pattern applies here as it did to the upload, and the same rule from the previous lesson applies too: branch on insertError.code if you need to distinguish a policy rejection from something else, rather than parsing the message text.

A second, already-connected client — someone else browsing the same feed — is subscribed to postgres_changes on the posts table, exactly as covered in the Realtime module. The moment the insert from step 2 commits, Postgres replicates that change out and this subscription fires with the new row as its payload:

supabase
.channel('posts-feed')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'posts' },
(payload) => {
// payload.new is the row just inserted in step 2
addPostToFeed(payload.new);
}
)
.subscribe();

This only works because posts was added to the supabase_realtime publication — the setup step from the Realtime module. Without that, the table’s changes are never replicated out, no matter how correctly this .on('postgres_changes', ...) listener is written; the insert in step 2 would still succeed, but this callback would simply never fire.

Nothing here is a special integration between Storage, the REST API, and Realtime — each service does exactly what it does in its own module, and the client code is what ties them together into a single user-facing feature. The uploading client never talks to the subscribing client directly; the flow works because both are talking to the same Supabase project, and Postgres’s replication stream is what connects the write on one side to the notification on the other.

flowchart LR
  upload["storage.from('photos').upload(file)"] --> insert["from('posts').insert({ photo_path, user_id })"]
  insert -->|"allowed only if RLS policy passes"| commit[("Row committed in Postgres")]
  commit -->|"replicated because posts is in supabase_realtime"| notify["postgres_changes INSERT event"]
  notify --> other["Other client's .on('postgres_changes', ...) callback fires"]
One feature, three services: upload, insert under RLS, then a live notification
In the photo-upload feature, which Supabase service does storage.from('photos').upload() belong to?
Why does the .from('posts').insert() call in step 2 succeed only for the correct user?
Why does the other client's postgres_changes subscription depend on something from the Realtime module specifically?
Why does error handling still matter in a multi-step flow like upload, then insert, then subscribe?