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
| 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}`,
}
})
// 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:
// 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}`,
}
}
)
// 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
| 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}` }
}
)
// 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:
// 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
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
// 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 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:
- Small files (< 5 MB) — Upload through Worker for inline validation
- Medium files (5-100 MB) — Presigned URLs for direct-to-R2 uploads
- Large files (> 100 MB) — Chunked upload with progress tracking
- Private files — Signed URLs with authorization checks
- 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.