Folder operations and hierarchical policies in Storage
Storage folders are path prefixes, not real directories. How to list, move and delete a whole folder with supabase-js, and how to write policies based on folder names.
Storage has no real directories. An object's name is its full path, like team_42/reports/q3.pdf, and "folders" are just the parts between slashes. That's why there's no single call to delete or move a folder, and why policies can match on path segments.
What you see
remove(['team_42/reports'])succeeds but nothing is deleted.list('team_42')showsreportsas an entry withid: null, not its files.- A folder created in Studio contains a file called
.emptyFolderPlaceholder.
Why it happens
remove() and move() work on exact object names. team_42/reports isn't an object, so there's nothing to remove. list() returns one level at a time: files at that level, plus one entry per sub-prefix (those have id: null). Studio creates an empty placeholder object so that an empty folder can exist at all.
Storage provides SQL helpers that split a name for use in policies:
| Function | For team_42/reports/q3.pdf returns |
|---|---|
storage.foldername(name) |
{team_42,reports} |
storage.filename(name) |
q3.pdf |
storage.extension(name) |
pdf |
How to fix it
Delete a folder by listing everything under it, then removing in batches:
async function listAll(bucket, prefix) { const files = [] const { data, error } = await supabase.storage.from(bucket).list(prefix, { limit: 1000 }) if (error) throw error for (const entry of data) { const path = `${prefix}/${entry.name}` if (entry.id === null) files.push(...await listAll(bucket, path)) // sub-folder else files.push(path) } return files } const paths = await listAll('team-files', 'team_42/reports') for (let i = 0; i < paths.length; i += 100) { const { error } = await supabase.storage.from('team-files').remove(paths.slice(i, i + 100)) if (error) throw error }A folder with more than 1000 entries at one level needs the
offsetoption to page through.Move or rename a folder by moving each file:
for (const path of await listAll('team-files', 'team_42/drafts')) { await supabase.storage.from('team-files').move(path, path.replace('team_42/drafts/', 'team_42/archive/')) }Write a team policy on the first folder. With paths like
<team_id>/...and a membership table:create policy "Team members read team files" on storage.objects for select to authenticated using ( bucket_id = 'team-files' and exists ( select 1 from public.team_members m where m.team_id::text = (storage.foldername(name))[1] and m.user_id = (select auth.uid()) ) );Copy it for
insert(aswith check),updateanddelete, and indexteam_members (user_id, team_id)so the check stays fast.Restrict a sub-folder by checking deeper segments. Only team admins may write under
<team_id>/billing/:create policy "Admins upload billing files" on storage.objects for insert to authenticated with check ( bucket_id = 'team-files' and (storage.foldername(name))[2] = 'billing' and exists ( select 1 from public.team_members m where m.team_id::text = (storage.foldername(name))[1] and m.user_id = (select auth.uid()) and m.role = 'admin' ) );
Permissions per operation: list needs select; move needs select and update; remove needs select and delete.
On SupaHost
Don't delete rows from storage.objects with SQL to clear a folder. Current Storage versions block direct deletes ("Direct deletion from storage tables is not allowed. Use the Storage API instead."), because removing the row would leave the file on disk. Use the API as shown, from a server with service_role for bulk clean-ups.