Storage
Store and serve files — avatars, uploads, exports — without standing up your own
object store. Storage lives in the same project as your
database, behind the same Data Gateway, and is
governed by the same .policy rules. You never
hold a storage credential; the anon key is safe to ship because access is
enforced server-side.
Buckets
Files live in buckets. A bucket is either public or private:
- Public — files are served through the global edge/CDN and readable by anyone with the URL. Good for avatars, product images, and other assets that aren’t secret.
- Private — files are never served directly. A read requires either a rule that grants the caller access, or a short-lived signed URL you mint on the server-scoped call.
Both kinds enforce your .policy rules on writes (and on reads for private
buckets), so who can upload, overwrite, or delete a file is decided by the same
authorization layer as your rows.
Create buckets from the Storage section of the dashboard. Pick public vs private up front — it decides how files are served.
Working with files
Every operation goes through zxcv.storage.from('bucket'). Paths are relative to
the bucket root (e.g. user-123/avatar.png). Each call returns { data, error },
with error set to null on success.
Upload
const { data, error } = await zxcv.storage
.from('avatars')
.upload('user-123/avatar.png', file)Every one of these is checked against your rules before it runs. A denied write
or a private read the caller isn’t allowed to make comes back as a 403; a
missing file is a 404.
Access rules — the storage surface
Storage isn’t a separate permission system. Your .policy file has a storage
surface alongside the ones for your tables, so the same language that decides
who can read a row decides who can upload to a bucket:
storage avatars {
# anyone can read (public bucket, served via the edge)
read: true
# a user may only write files under their own folder
write: request.auth.uid == path.segment(0)
}Because the gateway evaluates these rules on every operation, an attacker with the anon key still can’t upload outside their own folder or delete someone else’s file — the rule holds server-side.
Serving and transforming images
Files in public buckets are served straight from the global edge/CDN, so a
getPublicUrl link is fast worldwide with no extra setup. Storage also supports
image transform rules, so you can serve a resized or reformatted variant of
an uploaded image instead of the original — handy for thumbnails and avatars.
Configure transforms in the Storage section of the dashboard.
Task: upload an avatar
A complete flow — let a signed-in user replace their avatar and show it.
Create a public avatars bucket
In the dashboard’s Storage section, create a bucket named avatars and mark
it public. Add a storage rule so users can only write their own folder:
storage avatars {
read: true
write: request.auth.uid == path.segment(0)
}Upload the file
Namespace the path by user id so the rule above matches, and pass upsert in via
a fresh path (or overwrite the same one):
async function uploadAvatar(file: File) {
const { data: { user } } = await zxcv.auth.getUser()
if (!user) throw new Error('not signed in')
const path = `${user.id}/avatar.png`
const { error } = await zxcv.storage
.from('avatars')
.upload(path, file)
if (error) throw error
return path
}Show it
Because avatars is public, resolve a CDN URL and render it — no round trip
needed:
function avatarUrl(userId: string) {
const { data } = zxcv.storage
.from('avatars')
.getPublicUrl(`${userId}/avatar.png`)
return data.publicUrl
}For a private bucket, skip getPublicUrl and mint a
createSignedUrl(path, 3600) link instead — it grants time-boxed access
without making the file public.
Next steps
- Auth — sign users in so
request.auth.uidis set for your storage rules - SDK reference — the full storage and query API
- Database — store the file path alongside your rows