Skip to content

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 more
  • 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.

Database
NeonSupabase
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:setup creates it; bun run r2:cors applies 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.ts and bun run r2:cors read 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.json and infra/r2/lifecycle.json are the source of truth, applied with bun run r2:cors and bun 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, and http://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 "*" for AllowedOrigins on 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.
  • AllowedHeaders must include every header the browser sends on the PUT. The dropzone sends content-type, so content-type is 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_BUCKET and re-running bun run r2:setup and bun 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 S3Client is constructed with region: "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 the content-type on 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, ListObjectsV2 and 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.
  • HeadBucketCommand is the cheapest call that exercises the endpoint, the signature and the token's scope at once. That is what bun run verify uses.
Public access is DNS work, not a checkbox
  • A bucket is private until you connect a custom domain or enable the r2.dev subdomain. R2_PUBLIC_BASE_URL is unset by default and publicUrl() returns null, which is correct for anything a user uploaded.
  • r2.dev is 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 CacheControl on putObject. An object written without one is served with no caching directive and every read is a paid class B operation.
Credentials
  • R2_ACCOUNT_ID and R2_BUCKET are values, not secrets, but there is no reason to publish them. R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are 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/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. 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-store for 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 in src/lib/storage/index.ts can 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. XMLHttpRequest in <FileDropzone /> reports it; fetch still 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-type is 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.ts holds R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY. It must never be imported, transitively or otherwise, from a client component, and neither key ever carries a NEXT_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.

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.

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.