Storage
Next.js boilerplate with Supabase Storage
Object storage in your Supabase project, with the same row level security as tables.
Object storage on the Supabase project you already have. A migration creates a private bucket with row level security policies. Signed upload URLs are issued only after an authorisation check. You also get signed downloads, on-the-fly image transformation and a token-only dropzone that uploads straight to storage.
What Supabase Storage adds to the agent layer: 2 rules · 2 skills · 5 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Supabase Storage?
Pick it if
Teams already on Supabase who want files under the same policies as their rows. Same migrations, same pull request. Strongest for private per-user files (avatars, invoices, imports) where "the owner and nobody else" is the whole access model.
Watch out for
- Egress is billed. If you serve large media at volume, the transfer line will outgrow the storage line. That is the case R2 exists for.
- Policies are SQL against
storage.objects, so the key layout is part of your security model. Change the shape of your keys and every policy changes with it.
Show 4 moreShow fewer
- The service role key bypasses every policy. Server code that uses it does its own authorisation, and the RLS policies only guard what holds a user token.
- Image transformation is convenient and metered per origin image. Cheap for avatars, surprising for a gallery.
- Signed upload URLs last two hours and that cannot be shortened, so treat the URL itself as a credential.
- One bucket per access model, not per feature. Public and private objects in one bucket end in leaked files or a pile of policy exceptions.
What it costs
Part of your Supabase plan. Free: 1 GB stored and 5 GB egress. Pro: 100 GB stored, then $0.0213/GB, and 250 GB egress, then $0.09/GB ($0.03/GB for cached egress). Image transformations need Pro: 100 origin images included, then $5 per 1,000.
Prices change. Check with Supabase Storage before you commit.
registry/tested.yaml
Tested with Supabase Storage
Each pair was installed, typechecked, linted, built and booted together.
- Database
- Supabase
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
Not tested yet: Neon.
What it adds
What Supabase Storage adds to the repo
Read straight from the supabase-storage manifest, so it is exactly what lands in your repo.
Environment variables
SUPABASE_STORAGE_BUCKETRequired
Name of the bucket created by
supabase/migrations/20250101000200_storage_bucket.sql. Change it in both places or the app signs URLs for a bucket that does not exist.- Where to get it
- Supabase dashboard -> Storage -> Buckets (the migration creates it; the dashboard only confirms it)
- Placeholder
- uploads
STORAGE_DEV_UPLOADEROptional
Local-only escape hatch. Set it to any id and uploads are attributed to that id so the dropzone works before you have wired
resolveUploader()to your auth battery. Ignored when NODE_ENV is production, so it cannot become the thing that ships.- Where to get it
- Set it in .env.local only. Never in the hosting provider's environment.
- Placeholder
- dev-user
STORAGE_ALLOWED_ORIGINSOptional
Extra origins allowed to request a signed upload URL, comma separated. NEXT_PUBLIC_APP_URL is always allowed and does not belong here. A preview deployment on a generated hostname needs its origin listed, or the upload fails in the browser with a message that never mentions the real cause.
- Where to get it
- Supabase dashboard -> Project Settings -> API -> allowed origins, and your own reverse proxy
- Placeholder
- https://app.example.com,https://staging.example.com
Dependencies
- @supabase/supabase-js^2.117.0
- server-only^0.0.1
Files it writes
12 files, at these exact paths.
src/5 files
app/1 file
api/1 file
upload/1 file
- route.ts
components/1 file
upload/1 file
- file-dropzone.tsx
lib/3 files
storage/3 files
- authorize.ts
- index.ts
- keys.ts
supabase/1 file
migrations/1 file
- 20250101000200_storage_bucket.sql
tests/1 file
unit/1 file
- storage-keys.test.ts
variants/5 files
auth-none/1 file
src/1 file
lib/1 file
storage/1 file
- uploader.ts
auth-supabase/1 file
supabase/1 file
migrations/1 file
- 20250101000201_storage_policies.sql
auth-wired/1 file
src/1 file
lib/1 file
storage/1 file
- uploader.ts
errors-none/1 file
src/1 file
lib/1 file
storage/1 file
- report.ts
errors-sentry/1 file
src/1 file
lib/1 file
storage/1 file
- report.ts
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot env-required
- @slot legal-processors
- @slot verify-checks
The differentiator
What Supabase Storage teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (2)
Loaded when the agent opens a matching file.
Buckets and their policies are migrations, not console clicks
Loads onsupabase/migrations/**src/lib/storage/**.claude/rules/bucket-policies-as-code.md
Every bucket is created by a migration
- Buckets, their
publicflag, theirfile_size_limit, theirallowed_mime_typesand every policy onstorage.objectslive insupabase/migrations/. A bucket created in the dashboard exists in exactly one project: not in a teammate's local stack, not in a preview branch, not in the environment you promote to next month. - The first symptom of a hand-made bucket is "works on my machine". The second is a production bucket whose policies nobody can review, because they were never written down.
- Use
on conflict (id) do updateso applying the migration twice converges rather than failing. A storage migration should be safe to re-run against a project where the bucket already exists. - Changing the bucket name means changing it in the migration and in
SUPABASE_STORAGE_BUCKET. They are two halves of one decision.
Private by default
- New buckets are private. A public bucket serves every object to anyone who can guess a key, forever, with no way to revoke: that is a product decision, not a convenience, and it is made once per bucket.
- Public and private objects never share a bucket. One bucket per access model:
uploads(private, user-owned) and, if you need it,public-assets(deliberately public, for things that are public by nature). bun run verifyfails if the private bucket has been flipped to public. That check exists because the flip is one click in a dashboard and invisible in a diff.
Policies
storage.objectshas row level security on. With no policies, nothing is readable or writable by anon or authenticated roles: the correct default, and the reason every policy below it must be deliberate.- Policies are written against the key layout
<owner-id>/<prefix>/<uuid>-<name>, so(storage.foldername(name))[1]is the owner. Change the key layout and every policy changes with it: they are one design, not two. - Write the four operations separately (
select,insert,update,delete). A singlefor allpolicy hides the case where you meant to allow reads but not deletes. - Wrap
auth.uid()in a scalar subselect ((select auth.uid()::text)) so Postgres evaluates it once per statement rather than once per row. - Never grant anything to
anonon a private bucket. If anonymous reads are genuinely needed, that is a different, public bucket. - Policies are the second line of defence. The service role key used by
src/lib/storage/index.tsbypasses all of them, so the authorisation check insrc/lib/storage/authorize.tsis the first line, and the one that actually runs for every request this app makes.
Applying and testing
- Apply with
bun run db:resetlocally andbunx supabase db pushagainst a linked project. Never by pasting SQL into the dashboard's editor. - Test a policy by acting as a user, not as the service role: sign in as user A and attempt to read a key under user B's prefix. A test that runs with the service role key proves nothing, because the service role ignores policies.
Never sign a URL without an authorisation check, and never proxy uploads
Loads onsrc/lib/storage/**src/app/api/upload/**src/components/upload/**.claude/rules/signed-urls.md
A signed URL is a bearer credential
Whoever holds one can use it: no session, no cookie, no further checks, from any browser, until it expires. Treat it exactly as you would treat a password you just minted.
- Every route that signs anything calls
requireUploader(request)from@/lib/storage/authorizefirst, before it reads the body and before it touches storage. Nothing below that line may run for an anonymous or cross-origin request. - Authentication is not authorisation. A signed-in user asking for a signed URL
to someone else's key is authenticated and must still be refused. For reads
and deletes, check
isOwnedBy(key, uploader.id)or look the row up in your database. - Keys are derived on the server from the session:
objectKey({ ownerId: uploader.id, ... }). A key supplied in the request body is a request to write wherever the caller likes. - Keep lifetimes short. Fifteen minutes for a download is generous; Supabase fixes upload URLs at two hours and it cannot be shortened, which is one more reason to treat the URL as sensitive.
- Never log a signed URL, never put one in an analytics property, an error
report or a support conversation, and never render one into a page that is
cached.
cache-control: no-storeis set on the signing response for that reason.
Uploads go to storage, never through a route handler
- The browser uploads directly to Supabase with the URL the server signed. A route handler that receives the file body is wrong here for four separate reasons: the platform body limit (a few MB), function time billed for the whole transfer, memory pressure from buffering, and a second full copy of every byte across the network.
putObject()is for files your own code produces: a generated PDF, a thumbnail, an export. Not for anything a user is sending.- Progress belongs to the browser.
XMLHttpRequestin<FileDropzone />reports it;fetchstill cannot.
Server-only, always
src/lib/storage/index.tsholdsSUPABASE_SERVICE_ROLE_KEY, which bypasses every row level security policy on the project. It must never be imported, transitively or otherwise, from a client component.- The client is a singleton with
persistSession: false. A service client that tries to keep a session in a serverless function is sharing state between requests from different people. - Never expose a storage helper as a server action that takes a key from the caller without an ownership check. A server action is a public endpoint.
Validate before you sign
assertUploadable()checks the filename, the content type against the allowlist, and the claimed size. Allowlist, never blocklist.- The size the browser reports is a claim: it can ask for a 1 KB upload and send
5 GB. The enforcement is the bucket's
file_size_limit, set in the migration. Set both: the code check gives a good error message, the bucket check is what actually holds. - SVG is deliberately not in the allowlist. It is a document format that can execute script, and serving one from your own origin is stored XSS.
Deleting
- Deletes are authorised the same way as reads: prove ownership first. The service role key can delete anything in the bucket, including objects belonging to other users.
- Deleting the row and deleting the object are two operations that can fail independently. Delete the object first, then the row: an orphaned object costs storage, an orphaned row breaks the page.
Skills (2)
Invoked by name.
- /add-bucket-policy
Add or change a Supabase Storage bucket and its row level security policies as a migration, then prove the policy actually denies what it should.
.claude/skills/add-bucket-policy/SKILL.md
- /upload-flow
Add a complete file upload to a feature: authorised signing route, direct-to-storage upload, the database row that records the key, and the cleanup that stops orphans.
.claude/skills/upload-flow/SKILL.md
Solution docs (5)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Cleaning up orphaned uploads before they become the billEvery abandoned upload and every deleted row leaves an object nothing references. Here is where orphans come from, the sweeper that finds them, and the two-phase delete that stops making more.docs/solutions/supabase-storage/cleaning-up-orphaned-uploads.md
- Serving images from Supabase Storage without shipping 4 MB avatarsOn-the-fly transformation resizes at read time, but it is metered and it fights your cache. When to transform, when to resize on upload, and how signed URLs complicate both.docs/solutions/supabase-storage/image-transformation.md
- Public bucket or private bucket: decide once, per bucket, on purposeA public bucket serves every object to anyone who guesses a key, forever. A private one costs you a signing step and a cache problem. Here is how to choose, and why mixing them in one bucket goes wrong.docs/solutions/supabase-storage/public-vs-private-buckets.md
- Row level security on storage buckets, and why yours might not be runningStorage policies are SQL against storage.objects keyed on the path. Here is the policy set that works, the key layout it depends on, and why the service role key silently bypasses all of it.docs/solutions/supabase-storage/rls-on-storage-buckets.md
- Signed upload URLs versus proxying the file through your serverProxying uploads through a route handler hits body limits, doubles the transfer and bills you for the wait. Sign a URL instead, and get the order of the checks right.docs/solutions/supabase-storage/signed-upload-urls-vs-proxying.md
How it fits
What Supabase Storage needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Pairs well with
- An auth battery. Suggested, never added for you.
Cannot be combined with
No hard conflicts.
Compared with the alternatives
Build a repo with Supabase Storage
Free and MIT. The builder opens with Supabase Storage picked. You download the zip right away, and we email you the link too.