Skip to content

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 more
  • 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 public flag, their file_size_limit, their allowed_mime_types and every policy on storage.objects live in supabase/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 update so 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 verify fails 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.objects has 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 single for all policy 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 anon on 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.ts bypasses all of them, so the authorisation check in src/lib/storage/authorize.ts is the first line, and the one that actually runs for every request this app makes.
Applying and testing
  • Apply with bun run db:reset locally and bunx supabase db push against 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/authorize first, 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-store is 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. XMLHttpRequest in <FileDropzone /> reports it; fetch still cannot.
Server-only, always
  • src/lib/storage/index.ts holds SUPABASE_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.

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.

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.