DEV Community

sweet
sweet

Posted on

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

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 used at tanstackship.com.


Architecture Overview

Component Purpose Performance
Browser File selection, chunking, progress Client-side
TanStack Start (Worker) Auth, validation, presigned URLs ~5ms cold start
Cloudflare R2 Object storage, S3-compatible Global, no egress fees
Images Resize, format conversion Workers + R2

Multipart Form Upload

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

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

Direct-to-R2 Uploads (Presigned URLs)

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

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

When to Use Each Method

Method File Size Use Case Pros Cons
Form data via Worker < 5 MB Profile pics, documents Simple, validation in Worker Worker execution time
Presigned URL < 100 MB Media uploads, backups No Worker overhead, fast Extra round-trip for URL
Chunked upload > 100 MB Videos, datasets Pause/resume, progress Complex implementation

Chunked Upload for Large Files

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

Image Processing Pipeline

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

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

Access Control

Signed URLs for Private Content

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

File Validation Middleware

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

Cost Analysis: R2 vs S3 vs Local Storage

Storage Provider Storage (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 a production implementation of this architecture, see tanstackship.com.

Related Resources

Top comments (0)