Skip to Content
BuildStorage

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.

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:

app.policy
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:

app.policy
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.uid is set for your storage rules
  • SDK reference — the full storage and query API
  • Database — store the file path alongside your rows