File UploadCloudflare R2TanStack StartCloudflare WorkersStorageTypeScript

File Upload Architecture: R2, Multipart, and Streaming in TanStack Start

A comprehensive guide to file upload architecture in TanStack Start on Cloudflare Workers — covering multipart uploads, R2 object storage, streaming, chunked uploads for large files, and image processing pipelines.

Alex Chen
Alex Chen
July 8, 202613 min read

TL;DR: File upload is a critical feature for most SaaS applications — profile images, document attachments, media libraries, and data imports. TanStack Start on Cloudflare Workers, combined with R2 object storage, provides a complete upload architecture with edge-native performance. This guide covers multipart form uploads, direct-to-R2 uploads from the browser, streaming uploads for large files, image processing pipelines, access control, and a production-ready upload flow.


Architecture Overview

ComponentPurposePerformance
BrowserFile selection, chunking, progressClient-side
TanStack Start (Worker)Auth, validation, presigned URLs~5ms cold start
Cloudflare R2Object storage, S3-compatibleGlobal, no egress fees
ImagesResize, format conversionWorkers + R2

Multipart Form Upload

The simplest approach: upload files as part of a form submission using TanStack Start's server functions:

typescript
// server/uploads.ts
import { createServerFn } from "@tanstack/react-start"

export const uploadProfileImage = createServerFn({
  method: "POST",
}).handler(async ({ request, context }) => {
  const formData = await request.formData()
  const file = formData.get("avatar") as File

  if (!file) {
    throw new Error("No file provided")
  }

  // Validate file type and size
  const allowedTypes = ["image/jpeg", "image/png", "image/webp"]
  if (!allowedTypes.includes(file.type)) {
    return { success: false, error: "Invalid file type" }
  }

  if (file.size > 5 * 1024 * 1024) {
    return { success: false, error: "File too large (max 5MB)" }
  }

  // Upload to R2
  const key = `avatars/${context.user.id}/${file.name}`
  await context.env.MY_BUCKET.put(key, await file.arrayBuffer(), {
    httpMetadata: { contentType: file.type },
  })

  return {
    success: true,
    url: `https://r2.tanstackship.com/${key}`,
  }
})
typescript
// Client component
import { useMutation } from "@tanstack/react-query"
import { uploadProfileImage } from "../server/uploads"

function AvatarUpload() {
  const mutation = useMutation({
    mutationFn: (file: File) => {
      const formData = new FormData()
      formData.append("avatar", file)
      return uploadProfileImage({ data: formData })
    },
  })

  return (
    <div>
      <input
        type="file"
        accept="image/*"
        onChange={(e) => {
          const file = e.target.files?.[0]
          if (file) mutation.mutate(file)
        }}
      />
      {mutation.isPending && <progress>Uploading...</progress>}
      {mutation.data?.success && <img src={mutation.data.url} alt="avatar" />}
      {mutation.data?.error && <p className="error">{mutation.data.error}</p>}
    </div>
  )
}

Direct-to-R2 Uploads (Presigned URLs)

For larger files, use presigned URLs to upload directly to R2 from the browser, bypassing the Worker entirely:

typescript
// server/uploads.ts
export const getUploadUrl = createServerFn({ method: "GET" }).handler(
  async ({ data, context }: { data: { filename: string; contentType: string } }) => {
    const key = `uploads/${crypto.randomUUID()}-${data.filename}`

    // Generate presigned PUT URL (valid for 1 hour)
    const url = await context.env.MY_BUCKET.createPresignedUrl({
      key,
      method: "PUT",
      expiryInSeconds: 3600,
    })

    return {
      uploadUrl: url,
      publicUrl: `https://r2.tanstackship.com/${key}`,
    }
  }
)
typescript
// Client — direct upload to R2
async function uploadDirect(file: File) {
  const { uploadUrl } = await getUploadUrl({
    data: { filename: file.name, contentType: file.type },
  })

  const response = await fetch(uploadUrl, {
    method: "PUT",
    body: file,
    headers: { "Content-Type": file.type },
  })

  if (response.ok) {
    showToast("Upload complete!")
  }
}

When to Use Each Method

MethodFile SizeUse CaseProsCons
Form data via Worker< 5 MBProfile pics, documentsSimple, validation in WorkerWorker execution time
Presigned URL< 100 MBMedia uploads, backupsNo Worker overhead, fastExtra round-trip for URL
Chunked upload> 100 MBVideos, datasetsPause/resume, progressComplex implementation

Chunked Upload for Large Files

typescript
// server/uploads.ts
export const initiateMultipartUpload = createServerFn({ method: "POST" }).handler(
  async ({ data }: { data: { filename: string; parts: number } }) => {
    const uploadId = crypto.randomUUID()
    const key = `uploads/${uploadId}-${data.filename}`

    // Generate presigned URLs for each chunk
    const presignedUrls = await Promise.all(
      Array.from({ length: data.parts }, (_, i) =>
        context.env.MY_BUCKET.createPresignedUrl({
          key,
          method: "PUT",
          expiryInSeconds: 7200,
        })
      )
    )

    return { uploadId, key, presignedUrls, chunkSize: 5 * 1024 * 1024 } // 5MB chunks
  }
)

export const completeUpload = createServerFn({ method: "POST" }).handler(
  async ({ data }: { data: { key: string; uploadId: string } }) => {
    // Mark the upload as complete in your database
    await env.DB.prepare(
      "INSERT INTO uploads (id, key, user_id, created_at) VALUES (?, ?, ?, ?)"
    ).bind(data.uploadId, data.key, context.user.id, Date.now()).run()

    return { url: `https://r2.tanstackship.com/${data.key}` }
  }
)
typescript
// Client — chunked upload with progress
async function uploadLargeFile(file: File, onProgress: (pct: number) => void) {
  const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB
  const parts = Math.ceil(file.size / CHUNK_SIZE)

  const { uploadId, key, presignedUrls } = await initiateMultipartUpload({
    data: { filename: file.name, parts },
  })

  for (let i = 0; i < parts; i++) {
    const start = i * CHUNK_SIZE
    const chunk = file.slice(start, start + CHUNK_SIZE)

    await fetch(presignedUrls[i], {
      method: "PUT",
      body: chunk,
    })

    onProgress(((i + 1) / parts) * 100)
  }

  return await completeUpload({ data: { key, uploadId } })
}

Image Processing Pipeline

Combine R2 with Cloudflare Image Resizing or a Workers-based pipeline:

typescript
// server/images.ts
export const processAndUploadImage = createServerFn({ method: "POST" }).handler(
  async ({ data, context }) => {
    const { buffer, filename } = data

    // Generate multiple sizes
    const sizes = [
      { suffix: "thumb", width: 150, height: 150 },
      { suffix: "small", width: 400, height: 300 },
      { suffix: "full", width: 1200, height: 900 },
    ]

    const uploads = await Promise.all(
      sizes.map(async ({ suffix, width, height }) => {
        const key = `images/${filename}/${suffix}.webp`
        const processed = await resizeImage(buffer, { width, height, format: "webp" })

        await context.env.MY_BUCKET.put(key, processed, {
          httpMetadata: { contentType: "image/webp" },
        })

        return { suffix, url: `https://r2.tanstackship.com/${key}` }
      })
    )

    return { urls: uploads }
  }
)

Access Control

Signed URLs for Private Content

typescript
export const getSignedDownloadUrl = createServerFn({ method: "GET" }).handler(
  async ({ data, context }: { data: { key: string } }) => {
    // Check authorization
    const upload = await env.DB.prepare(
      "SELECT user_id FROM uploads WHERE key = ?"
    ).bind(data.key).first()

    if (!upload || upload.user_id !== context.user.id) {
      throw new Error("Unauthorized")
    }

    // Generate temporary download URL
    return await context.env.MY_BUCKET.createPresignedUrl({
      key: data.key,
      method: "GET",
      expiryInSeconds: 3600,
    })
  }
)

File Validation Middleware

typescript
// server/uploads.ts
const fileValidators = {
  image: {
    maxSize: 5 * 1024 * 1024, // 5MB
    types: ["image/jpeg", "image/png", "image/webp", "image/avif"],
  },
  document: {
    maxSize: 20 * 1024 * 1024, // 20MB
    types: [
      "application/pdf",
      "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      "text/csv",
    ],
  },
  video: {
    maxSize: 500 * 1024 * 1024, // 500MB
    types: ["video/mp4", "video/webm"],
  },
} as const

function validateFile(file: File, category: keyof typeof fileValidators) {
  const rules = fileValidators[category]

  if (!rules.types.includes(file.type as any)) {
    return `Invalid file type. Allowed: ${rules.types.join(", ")}`
  }
  if (file.size > rules.maxSize) {
    return `File too large. Maximum: ${rules.maxSize / 1024 / 1024}MB`
  }
  return null
}

Cost Analysis: R2 vs S3 vs Local Storage

Storage ProviderStorage (1TB)Egress (1TB)A Operations (10M)B Operations (10M)
Cloudflare R2$15.00$0.00$0.36$0.36
AWS S3 Standard$23.00$90.00$0.05$0.05
AWS S3 - IA$12.50$90.00$0.10$0.10
Backblaze B2$6.00$10.00$0.00$0.00

R2's zero egress fees are a significant advantage for SaaS applications where users frequently access uploaded files.


Production Security Checklist

  • File type validation on both client and server
  • File size limits enforced — reject oversized uploads before processing
  • Unique filenames using UUIDs to prevent path traversal
  • Content-Type headers set explicitly on R2 objects
  • Presigned URLs with short expiration (1 hour max for PUT, 1 day for GET)
  • Authorization check before generating download URLs
  • Upload rate limiting per user (prevent abuse)
  • Virus scanning for document uploads (WebAssembly-based ClamAV on Workers)
  • CORS configuration on R2 bucket restricts origins
  • Audit logging of all upload and download events

Conclusion

File upload architecture is a critical consideration for any SaaS application. TanStack Start + Cloudflare R2 provides a scalable, cost-effective foundation that handles everything from small profile images to multi-gigabyte media files.

The key architectural decisions:

  1. Small files (< 5 MB) — Upload through Worker for inline validation
  2. Medium files (5-100 MB) — Presigned URLs for direct-to-R2 uploads
  3. Large files (> 100 MB) — Chunked upload with progress tracking
  4. Private files — Signed URLs with authorization checks
  5. Images — Processing pipeline with multiple size variants

For more on R2 configuration and cost optimization, see the Cloudflare R2 Object Storage Guide. And to optimize and transform images after upload, check out Image Optimization for Modern Web Applications.