file storage · side by side
Cloudflare R2 vs Supabase Storage for a Next.js app
Both fill the file storage slot, so a generated repo carries one or the other, never both. Every line below is read out of the two manifests.
Short answer
Pick Cloudflare R2 if
Anything read far more often than it is written, and anything large. User uploads, video, model weights, datasets, backups you may need to pull out again.
Pick Supabase Storage 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.
Cloudflare R2 is tested with Neon. Supabase Storage isn't yet.
Side by side
Price, obligations, and the surface each one adds. No row is written by hand. This is manifest.yaml, rendered.
| From the manifest | Option ACloudflare R2 | Option BSupabase Storage |
|---|---|---|
| In one line | Cloudflare R2 S3-compatible object storage with free egress. You pay for storage and requests. | Supabase Storage Object storage in your Supabase project, with the same row level security as tables. |
| Pricing | Cloudflare R2 Free each month: 10 GB stored, 1M Class A and 10M Class B operations. Then $0.015 per GB-month, $4.50 per million Class A operations (writes, lists) and $0.36 per million Class B operations (reads). Egress is free. | Supabase Storage 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. |
| Best for | Cloudflare R2 Anything read far more often than it is written, and anything large. User uploads, video, model weights, datasets, backups you may need to pull out again. | Supabase Storage 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. |
| Trade-offsVerbatim from the manifest | Cloudflare R2
| Supabase Storage
|
| Required companionsAdded for you, with a reason | Cloudflare R2 Nothing. It stands on its own. | Supabase Storage
|
| Recommended alongsideSuggested, never added for you | Cloudflare R2
| Supabase Storage
|
| Env vars you will manageEvery one documented in docs/onboard.md | Cloudflare R2 7 variables · 4 required
| Supabase Storage 3 variables · 1 required
|
| Dependencies added | Cloudflare R2
| Supabase Storage
|
| MCP serversWritten into .mcp.json | Cloudflare R2 None. No extra agent tools from this one. | Supabase Storage None. No extra agent tools from this one. |
| Footprint in your repo | Cloudflare R2 13 files, plus 3 injections into shared stack files | Supabase Storage 12 files, plus 3 injections into shared stack files |
What changes in your repo
The paths each battery contributes, diffed. A path in the third list is written by both, so swapping rewrites that file rather than adding one.
Only with Cloudflare R2 (3)
infra/2 files
r2/2 files
- cors.json
- lifecycle.json
scripts/1 file
r2/1 file
- apply-config.ts
Only with Supabase Storage (2)
supabase/1 file
migrations/1 file
- 20250101000200_storage_bucket.sql
variants/1 file
auth-supabase/1 file
supabase/1 file
migrations/1 file
- 20250101000201_storage_policies.sql
Same path, different implementation (10)
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
tests/1 file
unit/1 file
- storage-keys.test.ts
variants/4 files
auth-none/1 file
src/1 file
lib/1 file
storage/1 file
- uploader.ts
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
Shared stack files Cloudflare R2 injects into
- env-required
- legal-processors
- verify-checks
Shared stack files Supabase Storage injects into
- env-required
- legal-processors
- verify-checks
Cloudflare R2 in your .env.local
# required
R2_ACCESS_KEY_ID=0123456789abcdef0123456789abcdef
R2_ACCOUNT_ID=0123456789abcdef0123456789abcdef
R2_BUCKET=uploads
R2_SECRET_ACCESS_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
# optional
R2_PUBLIC_BASE_URL=https://files.example.com
STORAGE_ALLOWED_ORIGINS=https://app.example.com,https://staging.example.com
STORAGE_DEV_UPLOADER=dev-user
Supabase Storage in your .env.local
# required
SUPABASE_STORAGE_BUCKET=uploads
# optional
STORAGE_ALLOWED_ORIGINS=https://app.example.com,https://staging.example.com
STORAGE_DEV_UPLOADER=dev-user
What changes for your agents
Each battery ships rules, skills, subagents and hooks that an agent loads before it touches the code that battery owns. Picking one is also picking how your agents behave in src/lib/storage/**.
Cloudflare R2
2
Skills
2
Rules
5
Solution docs
Rules (2)
Bucket configuration is code, and R2 is S3-compatible rather than S3
infra/r2/** · scripts/r2/** · src/lib/storage/**
Never sign a URL without an authorisation check, and never proxy uploads through a route
src/lib/storage/** · src/app/api/upload/** · src/components/upload/**
Skills (2)
/add-bucket
Add a second R2 bucket for a different access model, with its CORS and lifecycle configuration committed as code and applied by script rather than clicked into the dashboard.
/upload-flow
Add a complete file upload to a feature: authorised signing route, direct-to-R2 PUT, the database row that records the key, and the cleanup that stops orphans.
Subagents and hooks
None of its own. The foundation agents and guard hooks still ship.
Supabase Storage
2
Skills
2
Rules
5
Solution docs
Rules (2)
Buckets and their policies are migrations, not console clicks
supabase/migrations/** · src/lib/storage/**
Never sign a URL without an authorisation check, and never proxy uploads
src/lib/storage/** · src/app/api/upload/** · src/components/upload/**
Skills (2)
/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.
/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.
Subagents and hooks
None of its own. The foundation agents and guard hooks still ship.
What each one already knows
Solution docs land in docs/solutions/ in your repo and are published here, so you can read the failure modes before you commit.
Cloudflare R2 (5)
- Orphaned objects in R2: lifecycle rules and the sweep they cannot doDirect-to-bucket uploads leak objects nobody references. Lifecycle rules clean up a tmp/ prefix and abandoned multipart parts; owner-scoped orphans need a reconciliation job.docs/solutions/r2/cleaning-up-orphaned-uploads.md
- The R2 upload that fails in the browser and works in curl: bucket CORSA presigned PUT from a page is a cross-origin request. Without CORS rules on the bucket it fails with an error that never says CORS, and the signature gets blamed.docs/solutions/r2/cors-on-a-bucket.md
- Serving R2 objects publicly: custom domains, r2.dev and cache headersr2.dev is rate-limited and not for production. A custom domain puts Cloudflare's cache in front of the bucket, but only if you wrote Cache-Control at upload time.docs/solutions/r2/custom-domains-and-cache-headers.md
- Presigned PUT or multipart: picking an upload strategy for R2A single presigned PUT is right up to about 100 MB and restarts from zero when it fails. Above that, multipart is not an optimisation, it is the only thing that works.docs/solutions/r2/presigned-put-vs-multipart.md
- Zero egress fees, and the workloads where R2 actually beats S3Egress is the line that surprises people on an S3 bill. R2 charges nothing for it and charges for operations instead: here is the arithmetic for deciding, including where R2 loses.docs/solutions/r2/zero-egress-and-when-r2-beats-s3.md
Supabase Storage (5)
- 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
Which one to pick
From meta.bestFor and meta.tradeoffs. If a claim is not in the manifest, it is not on this page.
Pick Cloudflare R2 when
Anything read far more often than it is written, and anything large. User uploads, video, model weights, datasets, backups you may need to pull out again.
And accept that(6)
- No egress fee, but operations are billed. Many tiny reads can cost more than the bytes. Check Class B pricing against your access pattern, not just your storage volume.
- S3-compatible, not S3. The common surface works with the AWS SDK. Parts of the long tail (some checksum modes, ACLs, object lock, storage classes) do not. Test, do not assume.
- Authorisation is your job. R2 has no row level security, so "may this user read this key" is a decision your app makes on every request.
- A bucket is private until you attach a custom domain or enable the r2.dev subdomain, and r2.dev is rate-limited and not for production. Public serving means DNS work, not a checkbox.
- Strong read-after-write consistency, but no built-in image transforms. That is a separate Cloudflare product, or a resize on upload in your own code.
- Bucket config (CORS, lifecycle) lives in Cloudflare, not in your migrations. It needs its own committed files and a command to apply them. This battery ships both.
Pick Supabase Storage when
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.
And accept that(6)
- 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. - 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.
Questions people actually ask
- Should I choose Cloudflare R2 or Supabase Storage?
- Cloudflare R2 is best for anything read far more often than it is written, and anything large. User uploads, video, model weights, datasets, backups you may need to pull out again. Supabase Storage is best for 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. Both fill the file storage slot, so a generated repo carries one or the other, never both.
- How much do Cloudflare R2 and Supabase Storage cost?
- Cloudflare R2: Free each month: 10 GB stored, 1M Class A and 10M Class B operations. Then $0.015 per GB-month, $4.50 per million Class A operations (writes, lists) and $0.36 per million Class B operations (reads). Egress is free. Supabase Storage: 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.
- What changes in my repo if I switch from Cloudflare R2 to Supabase Storage?
- Cloudflare R2 writes 13 files, 7 environment variables and 3 dependencies, and installs 2 path-scoped rules, 2 skills and 5 solution docs. Supabase Storage writes 12 files, 3 environment variables and 2 dependencies, and installs 2 path-scoped rules, 2 skills and 5 solution docs.
Decide once, then build the repo that already knows the decision.
Either way you get that choice’s rules, skills and solution docs installed, plus the guard hooks, an onboarding doc for exactly these env vars, and the Compound Engineering loop. Free and MIT.