All playbooks
Stack setup
20 min read

Cloudflare R2 for Next.js

The setup I use to store invoices, signed contracts, receipt photos, and blog imagery on Cloudflare R2. S3 compatible, zero egress fees, and presigned uploads that work straight from the browser. The whole thing fits on the free tier for a long time.

Why R2 not S3

S3 charges you for storage and for egress. R2 charges you only for storage. For a site that serves images or downloads, this difference is the entire game. R2 also exposes an S3 compatible API, so every library that talks to S3 (the AWS SDK, presigned URLs, multipart upload, the lot) works against R2 with a different endpoint URL and credentials.

The free tier is 10 GB storage, 1 million Class A operations per month (uploads, list), and 10 million Class B operations (reads). That covers a personal site or a small SaaS for a long time. After the free tier, R2 is $0.015 per GB per month for storage and zero for egress; S3 is $0.023 per GB plus $0.09 per GB egress. The egress saving is what makes R2 not just cheaper but cheaper in a way that scales with success.

Egress fees are a tax on growth. R2 removes the tax.

Buckets, public vs private

Have two buckets, never one. A private bucket for anything that should require authorisation (invoice PDFs, signed contracts, raw uploads). A public bucket for assets that are fine on the open internet (blog hero images, OG cards, product imagery).

The private bucket has Public Access disabled. Reads always go through a signed URL or a server route. The public bucket has Public Access on and gets served through a custom domain (r2.sarmalinux.com) backed by a CNAME so you get Cloudflare's CDN automatically.

API tokens with least privilege

Create one R2 API token per environment. Production gets a token scoped to read + write on both buckets. Local dev gets a separate token, ideally with a smaller scope. Never reuse keys across projects; rotating one should not break another.

The four envs you actually need:

bash
# /etc/sarmalinux/env or .env.local R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=... R2_BUCKET=sarmalinux-private R2_PUBLIC_BUCKET=sarmalinux-public R2_PUBLIC_BASE=https://pub-xxx.r2.dev

The AWS SDK works as is

R2 implements the S3 protocol. Install the official AWS SDK v3, point it at the R2 endpoint, and most code you would write for S3 works unchanged.

ts
// lib/r2.ts import { S3Client } from '@aws-sdk/client-s3' export const r2 = new S3Client({ region: 'auto', endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID!, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, }, })

Server side upload

Smaller files (under 5 MB) go through a server action or route handler. The user uploads to your server, your server PUTs to R2, and you record the key in Postgres. Simple and safe.

ts
// app/api/upload/route.ts import { NextResponse } from 'next/server' import { PutObjectCommand } from '@aws-sdk/client-s3' import { r2 } from '@/lib/r2' import { randomUUID } from 'crypto' export async function POST(req: Request) { const form = await req.formData() const file = form.get('file') as File if (!file) return NextResponse.json({ error: 'no file' }, { status: 400 }) const key = `uploads/${randomUUID()}-${file.name}` const body = Buffer.from(await file.arrayBuffer()) await r2.send(new PutObjectCommand({ Bucket: process.env.R2_BUCKET!, Key: key, Body: body, ContentType: file.type, })) return NextResponse.json({ key }) }

Browser direct upload via signed URL

For anything over a few MB (videos, signed PDFs, multi page scans), do not stream through your server. Generate a presigned PUT URL, hand it to the browser, and let the browser upload straight to R2. Your server only sees the metadata.

ts
// app/api/sign-upload/route.ts import { NextResponse } from 'next/server' import { PutObjectCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import { r2 } from '@/lib/r2' import { randomUUID } from 'crypto' export async function POST(req: Request) { const { contentType, ext } = await req.json() const key = `uploads/${randomUUID()}.${ext}` const url = await getSignedUrl( r2, new PutObjectCommand({ Bucket: process.env.R2_BUCKET!, Key: key, ContentType: contentType, }), { expiresIn: 300 }, ) return NextResponse.json({ url, key }) }

On the client:

ts
async function uploadFile(file: File) { const ext = file.name.split('.').pop() const sig = await fetch('/api/sign-upload', { method: 'POST', body: JSON.stringify({ contentType: file.type, ext }), }).then(r => r.json()) await fetch(sig.url, { method: 'PUT', body: file, headers: { 'Content-Type': file.type } }) return sig.key }

Reading and signed reads

Public assets go via the public bucket URL, no signing needed. Private assets get a short lived signed URL (5 to 60 minutes is the sweet spot). Generate it server side, return it as JSON, the browser fetches the file.

ts
import { GetObjectCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' export async function signedReadUrl(key: string) { return getSignedUrl( r2, new GetObjectCommand({ Bucket: process.env.R2_BUCKET!, Key: key }), { expiresIn: 60 * 10 }, ) }

Public bucket and CDN

R2 gives you a free pub-xxx.r2.dev URL for any public bucket. For production, set up a custom domain (r2.sarmalinux.com in my case): R2 Bucket → Settings → Custom Domain → enter the subdomain. Cloudflare puts a CNAME in for you and the CDN is on by default.

Images served from the custom domain are cached at every Cloudflare PoP. Cache TTL is configurable in the dashboard; one week is a good default for content addressed assets.

Limits and cost worked example

A blog with 100 hero images at 200 KB each is 20 MB total storage, well inside the 10 GB free tier. Even if every image is fetched 10,000 times a month, the Class B operations stay well below the 10 million free allowance and the egress cost stays at zero.

For a heavier app with 50 GB of invoice PDFs and 200,000 reads per month, R2 storage is around 60 cents and egress is still zero. The same workload on S3 would be roughly $1.15 storage and around $9 egress on a single GB sized download set.

Pitfalls

Forgetting region: "auto"

R2 uses the literal string "auto" as its region. The AWS SDK throws if you set anything else. Easy to copy paste an S3 example with us-east-1 and wonder why nothing connects.

Leaking the public bucket URL into private flows

The default pub-xxx.r2.dev URL is publicly reachable and never expires. If you put a private object key into a public URL by accident, the file is now public forever (until you rotate the bucket name).

Massive CORS surprise

Browser uploads need CORS configured on the bucket. R2 → Bucket → Settings → CORS Policy. Allow your origin, PUT/POST/GET methods, and the Content-Type header, or every preflight will 403.

No object listing in browser

R2 does not give you a folder UI in the dashboard for individual objects in large buckets. Use the AWS CLI with the R2 endpoint or rclone. Save yourself the search.

Wrap up

R2 quietly removed one of the most annoying line items in any web business. If you are still on S3 and you do not need a specific S3 only feature (Glacier tiers, S3 Object Lambda), migrate. The SDK is the same, the cost is lower, and you stop paying tax on every download.

Want this done for you?

If you would rather skip the YAK shave and have someone who has done this fifty times set it up properly, that is what I do for a living.

Start a project