Cloudflare R2 is an S3-compatible object storage service with zero egress fees — a game-changer for SaaS applications. Unlike AWS S3, you pay no transfer costs when serving files to users. This guide covers everything you need to integrate R2 with TanStack Start: bucket setup, direct uploads, public serving, image resizing, access control, and cost comparisons. See a production R2 setup at tanstackship.com.
Why R2 for SaaS?
| Feature | Cloudflare R2 | AWS S3 | Google Cloud Storage |
|---|---|---|---|
| Egress fees | $0 — no charge for data transfer | $0.09/GB | $0.08-0.12/GB |
| Storage cost | $0.015/GB/mo | $0.023/GB/mo | $0.020/GB/mo |
| API compatibility | S3-compatible | Native | S3-compatible |
| Edge network | 330+ locations | Regional | Regional |
| Cache integration | Smart Tiering + Cache Reserve | S3 + CloudFront | CDN interop |
| Global latency | 30-50ms | 50-200ms (regional) | 50-200ms (regional) |
Setup: Bucket Configuration
# wrangler.jsonc — bind R2 bucket to your Worker
{
"name": "tanstack-ship",
"r2_buckets": [
{
"binding": "MY_BUCKET",
"bucket_name": "tanstack-ship-prod",
"preview_bucket_name": "tanstack-ship-staging"
}
]
}
Public Access Configuration
# Make bucket publicly accessible via custom domain
wrangler r2 bucket create tanstack-ship-prod --public
wrangler r2 bucket domain add tanstack-ship-prod r2.tanstackship.com
# Set CORS policy via S3 API
aws s3api put-bucket-cors --bucket tanstack-ship-prod \
--cors-configuration '{
"CORSRules": [{
"AllowedOrigins": ["https://tanstackship.com"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"]
}]
}' --endpoint-url https://<account>.r2.cloudflarestorage.com
Upload Patterns
Basic Upload from Worker
// server/uploads.ts
export const uploadFile = createServerFn({ method: "POST" }).handler(
async ({ request, context }) => {
const formData = await request.formData()
const file = formData.get("file") as File
const key = `uploads/${crypto.randomUUID()}-${file.name}`
const arrayBuffer = await file.arrayBuffer()
await context.env.MY_BUCKET.put(key, arrayBuffer, {
httpMetadata: {
contentType: file.type,
contentDisposition: `inline; filename="${file.name}"`,
},
customMetadata: {
uploadedBy: context.user.id,
originalName: file.name,
},
})
return {
key,
url: `https://r2.tanstackship.com/${key}`,
size: file.size,
}
}
)
Browser Direct Upload (Large Files)
// Client-side upload directly to R2
export const getUploadToken = createServerFn({ method: "GET" }).handler(
async ({ data, context }: { data: { filename: string; contentType: string } }) => {
const key = `uploads/${crypto.randomUUID()}-${data.filename}`
// Generate a presigned URL valid for 1 hour
const uploadUrl = await context.env.MY_BUCKET.createPresignedUrl({
key,
method: "PUT",
expiryInSeconds: 3600,
})
return {
key,
uploadUrl,
publicUrl: `https://r2.tanstackship.com/${key}`,
}
}
)
Image Processing with R2
Combine R2 with Cloudflare Image Resizing for on-the-fly transformations:
// Using Cloudflare Image Resizing via query parameters
function OptimizedImage({ src, width, height }: OptimizedImageProps) {
// Format: /cdn-cgi/image/width=N,quality=Q/format=F/path
const optimizedSrc = `/cdn-cgi/image/width=${width},format=webp,quality=80${src}`
return (
<img
src={optimizedSrc}
alt=""
width={width}
height={height}
loading="lazy"
/>
)
}
Image Processing Options
| Parameter | Values | Description |
|---|---|---|
width |
1-4096 | Target width in pixels |
height |
1-4096 | Target height in pixels |
format |
auto, webp, avif, jpeg, png | Output format |
quality |
1-100 | Image quality percentage |
fit |
scale-down, contain, cover, crop, pad | Resizing behavior |
sharpen |
0-10 | Sharpening strength |
blur |
0-250 | Gaussian blur radius |
Programmatic Image Processing
export const processAndStore = createServerFn({ method: "POST" }).handler(
async ({ data, context }: { data: { buffer: ArrayBuffer; filename: string } }) => {
// Store original
const originalKey = `originals/${filename}`
await context.env.MY_BUCKET.put(originalKey, data.buffer)
// Generate thumbnails (processed on access via Image Resizing)
const variants = [
{ key: `thumbs/150/${filename}`, width: 150 },
{ key: `thumbs/400/${filename}`, width: 400 },
{ key: `thumbs/800/${filename}`, width: 800 },
]
for (const variant of variants) {
// Store a small placeholder — actual resizing happens via /cdn-cgi/image/
await context.env.MY_BUCKET.put(variant.key, data.buffer, {
customMetadata: { originalWidth: variant.width.toString() },
})
}
return {
original: `https://r2.tanstackship.com/${originalKey}`,
thumbnails: variants.map((v) => ({
width: v.width,
url: `https://r2.tanstackship.com/${v.key}`,
})),
}
}
)
Access Control
Private Bucket with Signed URLs
export const getSignedFileUrl = createServerFn({ method: "GET" }).handler(
async ({ data, context }: { data: { key: string } }) => {
// 1. Check permissions
const fileMeta = await context.env.MY_BUCKET.head(data.key)
const ownerId = fileMeta?.customMetadata?.uploadedBy
if (ownerId && ownerId !== context.user.id) {
throw new Error("Unauthorized")
}
// 2. Generate temporary URL
const url = await context.env.MY_BUCKET.createPresignedUrl({
key: data.key,
method: "GET",
expiryInSeconds: 3600,
})
return { url }
}
)
Cost Optimization
R2 Tiering
Hot Tier (default): $0.015/GB/mo
→ Objects accessed frequently (>1x per month)
Infrequent Access Tier: $0.01/GB/mo
→ Objects accessed less than once per month
→ $0.01/GB retrieval fee
Smart Tiering (auto): Automatically moves objects between tiers
→ Based on last access time
→ No manual lifecycle rules needed
Cost Comparison for a SaaS with 100GB Storage and 1TB Monthly Egress
| Provider | Storage (100GB) | Egress (1TB) | Total/Month |
|---|---|---|---|
| Cloudflare R2 | $1.50 | $0.00 | $1.50 |
| AWS S3 Standard | $2.30 | $90.00 | $92.30 |
| AWS S3 + CloudFront | $2.30 | $85.00 (CF egress) | $87.30 |
| Backblaze B2 | $0.60 | $10.00 | $10.60 |
Lifecycle Rules
// Set via S3 API or wrangler
// Auto-delete temporary uploads after 24 hours
export const cleanupTempUploads = async (env: Env) => {
const objects = await env.MY_BUCKET.list({ prefix: "temp/" })
const now = Date.now()
for (const obj of objects.objects) {
const age = now - obj.uploaded.getTime()
if (age > 24 * 60 * 60 * 1000) {
await env.MY_BUCKET.delete(obj.key)
}
}
}
Production Checklist
- [ ] Bucket CORS configured for allowed origins
- [ ] Public bucket has custom domain with TLS
- [ ] Presigned URLs have short expiry (1 hour for uploads, 24 hours for downloads)
- [ ] File type and size validation before upload
- [ ] Unique filenames (UUID-based) to prevent collisions
- [ ] Cache Reserve enabled for frequently accessed objects
- [ ] Smart Tiering enabled for cost optimization
- [ ] Lifecycle rules for temp file cleanup
- [ ] Audit logging for all upload and delete operations
- [ ] Access control enforced for private files
Conclusion
Cloudflare R2's combination of zero egress fees, global edge network, and S3 compatibility makes it the ideal object storage choice for SaaS applications. Integrated with TanStack Start on Cloudflare Workers, files flow from user upload to edge storage to user download without leaving the Cloudflare ecosystem — minimizing latency and cost.
The key decision points:
- Public files (images, assets) → Serve directly from R2 with Image Resizing
- Private files (documents, reports) → Signed URLs with auth check
- Large files (videos, datasets) → Presigned upload URLs + chunked upload
For a production R2 implementation, see the file upload architecture at tanstackship.com.
Top comments (0)