R2 is the S3 you already know, with a bill that does not punish your traffic
Zero egress fees, S3-compatible API, signed URLs for private files, multipart uploads for the large ones. We architect R2 into your application so the storage line on the invoice stops being the surprise it usually is.
Why this stack
Zero egress fees is the difference
S3 charges by request and by gigabyte read. R2 charges by request and by gigabyte stored, period. For an application that serves the same file repeatedly (avatars, images, static assets, media), the bill drops by an order of magnitude. The exact number depends on read pattern; we model it before signing.
S3-compatible API means your tooling works
The AWS SDK talks to R2 with one config change. Existing migrations, libraries, CLI scripts and infrastructure-as-code all work. Migrating is not a rewrite; it is a credential swap plus a copy step.
Tied to the Cloudflare edge when you need it
R2 sits on the same global network as Workers, Cache and Images. Adding image transformations, signed URLs validated at the edge, or per-request authorisation costs no extra round trip. The pieces compose.
Predictable pricing, no surprise bills
Storage cost, request cost, no egress cost. Three line items, all linear with usage. The famous storage-cost surprise that hits teams on S3 with high read traffic does not exist on R2. The bill becomes a budget number.
Multi-tenant isolation that actually scales
One R2 account, many buckets, or one bucket with per-tenant key prefixes. Signed URLs enforce the boundary at issue time. We pick the pattern based on isolation requirements and operational complexity, not on what is fashionable.
What we build with it
R2 bucket setup with proper IAM
Account-scoped tokens, bucket-scoped tokens, separate credentials for read-only consumers (web app) versus full-access producers (backend writes).
Signed URL generation
Pre-signed URLs for upload and download, TTL configured per use case, signature validated server-side or at the edge via a Worker.
CORS policy configuration
Origin allowlist tight to the production domains, method allowlist per use case, preflight cached for performance.
Image upload pipeline
Client requests a pre-signed URL, uploads directly to R2, the server validates and writes the metadata row, optional virus scan and image transformation in between.
File metadata in the database
A `files` table in Postgres carries the canonical record (owner, size, type, sha256, status, soft-delete). R2 is the bytes; the database is the truth.
Migration from S3
Existing bucket replicated to R2, application config switched, old bucket decommissioned with a rollback window. We have done this end of file; the playbook is reusable.
CDN strategy
Cloudflare native CDN for cacheable assets (default), Vercel's CDN for application static, or a third-party CDN where the buyer requires it. The R2 origin stays the same.
Multi-tenant bucket structure
Either bucket-per-tenant (high isolation, more operational work) or single bucket with `tenant_id/` key prefix (lower isolation, simpler ops). We document the trade-off and the buyer signs off.
Backup and lifecycle rules
Lifecycle policies for cold-storage tiers, off-account replication for disaster recovery, scheduled snapshots where the compliance regime requires them.
Per-tenant quota tracking
A nightly job tallies storage per tenant, writes the result back into the database, surfaces overages in the admin dashboard.
Workers integration
Image transformations, signed URL validation, custom routing logic at the edge without an extra service to operate.
Image transformation pipeline
Resize, format conversion (AVIF, WebP), quality tuning, all at the edge. Saves bandwidth and improves Core Web Vitals without adding a separate image service.
Signed-URL uploads to R2 with database-side metadata
The client gets a pre-signed URL, uploads directly to R2, then notifies the server, which validates and writes the canonical `files` row. R2 holds the bytes; Postgres holds the truth.
Most "R2 quickstart" guides show you the dashboard and stop. Production R2 is the upload pipeline below: the client never receives the secret key, the file goes straight to R2 without proxying through your server, and the canonical metadata row gets written only after the upload completes. Three files; no surprise costs, no leaked credentials, no orphaned files in the bucket.
1. Pre-signed upload URL
A Server Action mints a pre-signed PUT URL with a short TTL. The client uploads directly to R2 using that URL; the server never sees the bytes.
// app/[lang]/(app)/upload/actions.ts
'use server'
import { z } from 'zod'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { randomUUID } from 'crypto'
import { r2 } from '@/lib/r2/client'
import { getServerSession } from '@/lib/auth/server'
const Input = z.object({
filename: z.string().min(1).max(255),
contentType: z.enum(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']),
size: z.number().int().positive().max(50 * 1024 * 1024),
})
export async function requestUploadUrl(
raw: unknown,
): Promise<{ ok: true; key: string; url: string } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
const key = `${session.tenantId}/${randomUUID()}-${parsed.data.filename}`
const url = await getSignedUrl(
r2,
new PutObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
ContentType: parsed.data.contentType,
ContentLength: parsed.data.size,
}),
{ expiresIn: 60 },
)
return { ok: true, key, url }
}
2. The R2 client
The AWS SDK points at R2 with one config change: the endpoint. Everything else stays standard, so existing AWS tooling and CLIs work without modification.
// src/lib/r2/client.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!,
},
})
3. The client uploads directly to R2
The browser uses the pre-signed URL with a single PUT request. The progress event drives the UI; no server round-trip per chunk.
// app/[lang]/(app)/upload/UploadForm.tsx
'use client'
import { useState } from 'react'
import { requestUploadUrl } from './actions'
import { confirmUpload } from './confirm'
export function UploadForm() {
const [progress, setProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
async function handleFile(file: File) {
setError(null)
setProgress(0)
const reqResult = await requestUploadUrl({
filename: file.name,
contentType: file.type,
size: file.size,
})
if (!reqResult.ok) {
setError(reqResult.error)
return
}
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('PUT', reqResult.url)
xhr.setRequestHeader('Content-Type', file.type)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100))
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) resolve()
else reject(new Error(`R2 returned ${xhr.status}`))
})
xhr.addEventListener('error', () => reject(new Error('upload failed')))
xhr.send(file)
})
await confirmUpload({ key: reqResult.key, size: file.size })
}
return (
<div>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void handleFile(file)
}}
/>
{progress > 0 && progress < 100 ? <progress value={progress} max={100} /> : null}
{error ? <p role="alert">{error}</p> : null}
</div>
)
}
4. The confirm step writes the metadata row
The client tells the server "the upload succeeded for this key". The server verifies the object exists in R2 (HEAD request), then writes the canonical files row.
// app/[lang]/(app)/upload/confirm.ts
'use server'
import { HeadObjectCommand } from '@aws-sdk/client-s3'
import { z } from 'zod'
import { r2 } from '@/lib/r2/client'
import { createServerClient } from '@/lib/supabase/server'
import { getServerSession } from '@/lib/auth/server'
const Input = z.object({
key: z.string().min(1).max(500),
size: z.number().int().positive(),
})
export async function confirmUpload(
raw: unknown,
): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
const session = await getServerSession()
if (!session) return { ok: false, error: 'unauthorised' }
const parsed = Input.safeParse(raw)
if (!parsed.success) return { ok: false, error: 'invalid input' }
// Bind the key to the authenticated tenant; the prefix encodes the tenant.
if (!parsed.data.key.startsWith(`${session.tenantId}/`)) {
return { ok: false, error: 'invalid key' }
}
// Verify the object actually landed in R2.
let head
try {
head = await r2.send(
new HeadObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: parsed.data.key,
}),
)
} catch {
return { ok: false, error: 'object not found in R2' }
}
const supabase = await createServerClient()
const { data, error } = await supabase
.from('files')
.insert({
tenant_id: session.tenantId,
uploader_id: session.userId,
r2_key: parsed.data.key,
content_type: head.ContentType ?? 'application/octet-stream',
size_bytes: head.ContentLength ?? parsed.data.size,
status: 'ready',
})
.select('id')
.single()
if (error) return { ok: false, error: error.message }
return { ok: true, id: data.id }
}
5. What this buys you
The server never sees the bytes, so the upload scales independently of your function execution budget. The R2 prefix encodes the tenant, so a malicious client cannot upload into another tenant's namespace even with a leaked signed URL. The canonical metadata row writes only after the upload succeeds, so orphaned files in the bucket are a non-issue. The bill drops to storage and request cost; egress is zero whether you serve the file once or a million times.
That is the cost line the R2 marketing leads with, configured into a real production flow instead of left as a screenshot in a dashboard tour.
Frequently asked questions
R2 versus S3 — when to pick which?
R2 when read traffic is non-trivial (any user-facing application serving the same files repeatedly). S3 when integration with the AWS ecosystem (Athena, Glue, Lambda, EventBridge) is the main draw and read traffic is light. For most SaaS, R2 wins on bill, ties on tooling.
How do egress costs actually compare?
S3 charges roughly nine cents per gigabyte out (the first 10 TB; cheaper at scale). R2 charges zero per gigabyte out, full stop. For an application serving 1 TB per month of avatars and images, that is a hundred dollars saved per month per terabyte. Application traffic compounds; the savings do too.
How long does migration from S3 take?
A week for a medium application. The copy itself runs in the background via `rclone` or AWS CLI sync; the cutover is one day with the application reading from both for a brief window, then R2-only. We have a runbook.
Can we do image transformations on R2?
Yes, via Cloudflare Images or a Worker that reads from R2 and emits resized variants. We pick based on volume and required formats; both keep the original bytes on R2.
How do you enforce per-tenant isolation?
Bucket-per-tenant for the strictest case (regulated industries, large enterprise customers). Prefix-per-tenant with signed-URL enforcement for everything else. The application code does not know the difference; the helper function picks the right key based on a single config flag.
What about CDN — do we still need one?
Cloudflare's network sits in front of R2 by default for the cacheable case. For application static (Next.js `_next/static`) Vercel's CDN is already there. We only add a separate CDN where the buyer needs region-specific routing or a particular provider for compliance.
Is R2 EU data residency compliant?
R2 supports region-locked buckets where the data stays in the EU. We document the residency posture and configure the bucket region in the contract. The application code does not need to change.
Tell us about your storage workload
A scoping call, a concrete number in the first reply, no agency theater. New R2 deployments and S3 migrations both welcome.