DEV Community

wellallyTech
wellallyTech

Posted on • Originally published at tanstackship.com

Cloudflare R2: Object Storage for Edge Applications and SaaS

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    }
  }
)
Enter fullscreen mode Exit fullscreen mode

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}`,
    }
  }
)
Enter fullscreen mode Exit fullscreen mode

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"
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

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}`,
      })),
    }
  }
)
Enter fullscreen mode Exit fullscreen mode

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 }
  }
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Public files (images, assets) → Serve directly from R2 with Image Resizing
  2. Private files (documents, reports) → Signed URLs with auth check
  3. Large files (videos, datasets) → Presigned upload URLs + chunked upload

For a production R2 implementation, see the file upload architecture at tanstackship.com.

Related Resources

Top comments (0)