Storage
Next.js boilerplate with Cloudflare R2
S3-compatible object storage with free egress. You pay for storage and requests.
S3-compatible object storage with no egress fees. You get a private bucket, presigned PUT uploads issued only after an authorisation check, and presigned downloads. Bucket CORS and lifecycle rules live in the repo as configuration. A token-only dropzone uploads straight to R2.
What Cloudflare R2 adds to the agent layer: 2 rules · 2 skills · 5 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Cloudflare R2?
Pick it 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.
Watch out for
- 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.
Show 4 moreShow fewer
- 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.
What it costs
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.
Prices change. Check with Cloudflare R2 before you commit.
registry/tested.yaml
Tested with Cloudflare R2
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Cloudflare R2 adds to the repo
Read straight from the r2 manifest, so it is exactly what lands in your repo.
Environment variables
R2_ACCOUNT_IDRequired
Cloudflare account id. It forms the S3 endpoint
https://<account-id>.r2.cloudflarestorage.com. Not a secret, but there is no reason to publish it either.- Where to get it
- Cloudflare dashboard -> R2 -> Overview (the account id in the sidebar, or in any bucket's S3 API endpoint)
- Placeholder
- 0123456789abcdef0123456789abcdef
R2_ACCESS_KEY_IDRequired
Access key id of an R2 API token. Scope the token to this one bucket with Object Read & Write: an account-wide token in a web app is a blast radius you never needed.
- Where to get it
- Cloudflare dashboard -> R2 -> API -> Manage API tokens -> Create API token
- Placeholder
- 0123456789abcdef0123456789abcdef
R2_SECRET_ACCESS_KEYRequired
Secret half of the R2 API token. Shown once when the token is created and never again. Server-side only, never NEXT_PUBLIC_.
- Where to get it
- Cloudflare dashboard -> R2 -> API -> Manage API tokens (copy it at creation time)
- Placeholder
- 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
R2_BUCKETRequired
Bucket name.
bun run r2:setupcreates it;bun run r2:corsapplies the committed CORS rules.- Where to get it
- Cloudflare dashboard -> R2 -> Overview -> Create bucket, or the r2:setup script
- Placeholder
- uploads
R2_PUBLIC_BASE_URLOptional
Base URL of a custom domain connected to the bucket, for objects that are public by nature. Leave it unset and every read goes through a short-lived presigned URL, which is the right default for user uploads.
- Where to get it
- Cloudflare dashboard -> R2 -> your bucket -> Settings -> Public access -> Connect Domain
- Placeholder
- https://files.example.com
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. Both
src/lib/storage/authorize.tsandbun run r2:corsread this same variable, so the rule written onto the bucket and the rule the API enforces cannot drift apart, which is the failure mode worth avoiding, because a missing CORS origin fails in the browser with a message that never mentions CORS.- Where to get it
- Cloudflare dashboard -> R2 -> your bucket -> Settings -> CORS policy
- Placeholder
- https://app.example.com,https://staging.example.com
Dependencies
- @aws-sdk/client-s3^3.1141.0
- @aws-sdk/s3-request-presigner^3.1141.0
- server-only^0.0.1
Scripts
- bun run r2:cors
bun scripts/r2/apply-config.ts cors
- bun run r2:lifecycle
bun scripts/r2/apply-config.ts lifecycle
- bun run r2:setup
bun scripts/r2/apply-config.ts create
Files it writes
13 files, at these exact paths.
infra/2 files
r2/2 files
- cors.json
- lifecycle.json
scripts/1 file
r2/1 file
- apply-config.ts
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
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 Cloudflare R2 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.
Bucket configuration is code, and R2 is S3-compatible rather than S3
Loads oninfra/r2/**scripts/r2/**src/lib/storage/**.claude/rules/bucket-config-as-code.md
CORS and lifecycle rules live in the repo
infra/r2/cors.jsonandinfra/r2/lifecycle.jsonare the source of truth, applied withbun run r2:corsandbun run r2:lifecycle. Rules clicked into the Cloudflare dashboard exist in one account, are invisible in review, and are the reason "uploads work in production but not in preview" takes an afternoon to diagnose.- Every origin that uploads must appear in
AllowedOrigins: production, each preview URL you upload from, andhttp://localhost:3000. A missing origin fails in the browser with a message that never says CORS, and the time goes into debugging the signature instead. - Never use
"*"forAllowedOriginson a bucket that accepts writes. A presigned URL is a bearer credential, and a wildcard lets any page a victim visits use one it managed to obtain. AllowedHeadersmust include every header the browser sends on the PUT. The dropzone sendscontent-type, socontent-typeis listed. Add a header to the upload and add it here in the same change.- Lifecycle rules are the only thing that cleans up abandoned data. Keep the
tmp/expiry and the incomplete-multipart abort: multipart parts are stored and billed even though the object does not exist, and nothing else ever removes them. - Changing the bucket name means changing
R2_BUCKETand re-runningbun run r2:setupandbun run r2:cors. A new bucket starts with no CORS rules at all.
The region is always auto
- R2 has one global namespace and no S3-style regions. The
S3Clientis constructed withregion: "auto", and passing a real AWS region makes the request signature fail with an error that does not mention the region. - The endpoint is
https://<R2_ACCOUNT_ID>.r2.cloudflarestorage.com, built from the account id. It is not the bucket's public URL and never appears in a browser. - A location hint chosen when the bucket was created decides where the data first lands. It is not a region and it is not part of any URL.
- If a call fails with a signature error, check these three before anything
else:
region: "auto", the endpoint host, and whether thecontent-typeon the PUT matches the one that was signed.
S3-compatible is not S3
- The common surface works with
@aws-sdk/client-s3, which is why this battery uses it. The long tail does not: some checksum modes, ACLs, object lock and storage classes are unsupported or behave differently. - Test anything beyond
PutObject,GetObject,HeadObject,DeleteObject,ListObjectsV2and the multipart commands rather than assuming it works. A parameter R2 does not implement is usually ignored rather than rejected, which is worse than an error. - Do not set ACLs. R2 has no per-object ACLs: a bucket is private until a custom domain is attached, and that is the whole access model.
HeadBucketCommandis the cheapest call that exercises the endpoint, the signature and the token's scope at once. That is whatbun run verifyuses.
Public access is DNS work, not a checkbox
- A bucket is private until you connect a custom domain or enable the
r2.devsubdomain.R2_PUBLIC_BASE_URLis unset by default andpublicUrl()returns null, which is correct for anything a user uploaded. r2.devis rate-limited and explicitly not for production. Use a custom domain, which also puts Cloudflare's cache in front of the bucket.- Cache headers are set at write time via
CacheControlonputObject. An object written without one is served with no caching directive and every read is a paid class B operation.
Credentials
R2_ACCOUNT_IDandR2_BUCKETare values, not secrets, but there is no reason to publish them.R2_ACCESS_KEY_IDandR2_SECRET_ACCESS_KEYare credentials with full read/write on the bucket and are server-side only.- The secret is shown once at token creation. Rotating means creating a new token, deploying both halves, then deleting the old one, in that order.
Never sign a URL without an authorisation check, and never proxy uploads through a route
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 check, 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. Before
signing a read or a delete, call
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 taken from the request body is a request to write wherever the caller likes, and the first segment of the key is the entire ownership model. - Keep lifetimes short. Fifteen minutes is generous for a page that renders an image; a day means the URL outlives the chat message it was pasted into. R2 caps a presigned URL at seven days and that is a limit, not a suggestion.
- 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. The signing response sets
cache-control: no-storefor that reason. - R2 has no row level security and no equivalent. There is no second line of
defence behind the check in
authorize.ts: the credentials insrc/lib/storage/index.tscan read, overwrite and delete every object in the bucket. Whatever that file decides is what happens.
Uploads go straight to R2, never through a route handler
- The browser uploads with the URL the server signed. A route handler that receives the file body is wrong here for four separate reasons: the platform's request body limit (a few megabytes on most serverless hosts), 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. Never for anything a user is sending.- Anything above roughly 100 MB wants multipart, not a single presigned PUT. A failed PUT restarts from zero.
- Progress belongs to the browser.
XMLHttpRequestin<FileDropzone />reports it;fetchstill cannot.
Validate before you sign
assertUploadable()checks the filename, the content type against the allowlist and the claimed size. Allowlist, never blocklist: a blocklist loses to the next extension somebody thinks of.- The size the browser reports is a claim: it can request a 1 KB upload and then send 5 GB. R2 has no per-bucket file size limit to fall back on, so a short URL lifetime, a narrow content type and a lifecycle rule that expires unconfirmed objects are what actually bound the damage.
content-typeis part of the signature. A PUT that sends a different value is rejected with a signature mismatch, which is the most common cause of "my upload returns 403". The dropzone sends exactly the headers the signing response returned; keep it that way.- SVG is deliberately absent from the allowlist. It is a document format that can execute script, and serving one from your own origin is stored XSS.
Server-only, always
src/lib/storage/index.tsholdsR2_ACCESS_KEY_IDandR2_SECRET_ACCESS_KEY. It must never be imported, transitively or otherwise, from a client component, and neither key ever carries aNEXT_PUBLIC_prefix.- 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.
- Scope the R2 API token to one bucket with Object Read & Write. An account-wide token in a web app is a blast radius you never needed.
Reading objects
- Private by default: reads go through
getSignedDownloadUrl(). - If an object is genuinely public, do not sign it at all. Serve it from
publicUrl()through a custom domain, where Cloudflare's cache answers most requests and the zero egress fee does the rest. Signing a public asset defeats the cache and costs a class B operation per view. - Never mix public and private objects in one bucket. One bucket per access model.
Deleting
- Deletes are authorised the same way as reads: prove ownership first. These credentials can delete anything in the bucket.
- Deleting the row and deleting the object are two operations that fail independently. Delete the object first, then the row: an orphaned object costs storage, an orphaned row breaks the page.
- S3-compatible deletes are idempotent, so a cleanup job may safely delete a key that has already gone.
Skills (2)
Invoked by name.
- /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.
.claude/skills/add-bucket/SKILL.md
- /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.
.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.
- 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
How it fits
What Cloudflare R2 needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
Nothing. Cloudflare R2 stands on its own.
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 Cloudflare R2
Free and MIT. The builder opens with Cloudflare R2 picked. You download the zip right away, and we email you the link too.