TL;DR: Datei-Uploads sind eine entscheidende Funktion für die meisten SaaS-Anwendungen – Profilbilder, Dokumentenanhänge, Medienbibliotheken und Datenimporte. TanStack Start auf Cloudflare Workers in Kombination mit R2 Object Storage bietet eine vollständige Upload-Architektur mit Edge-nativer Leistung. Dieser Leitfaden behandelt Multipart-Formular-Uploads, direkte Uploads zu R2 aus dem Browser, Streaming-Uploads für große Dateien, Bildverarbeitungspipelines, Zugriffskontrolle und einen produktionsreifen Upload-Ablauf.
Architekturübersicht
| Komponente | Zweck | Leistung |
|---|---|---|
| Browser | Dateiauswahl, Chunking, Fortschritt | Clientseitig |
| TanStack Start (Worker) | Authentifizierung, Validierung, Presigned URLs | ~5ms Kaltstart |
| Cloudflare R2 | Objektspeicher, S3-kompatibel | Global, keine Egress-Gebühren |
| Bilder | Größenänderung, Formatkonvertierung | Workers + R2 |
Multipart-Formular-Upload
Der einfachste Ansatz: Laden Sie Dateien als Teil einer Formularübermittlung mithilfe der Serverfunktionen von TanStack Start hoch:
// 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")
}
// Dateityp und -größe validieren
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)" }
}
// Auf R2 hochladen
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-Komponente
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)
Verwenden Sie für größere Dateien Presigned URLs, um direkt aus dem Browser auf R2 hochzuladen und den Worker komplett zu umgehen:
// 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}`
// Presigned PUT-URL generieren (gilt für 1 Stunde)
const url = await context.env.MY_BUCKET.createPresignedUrl({
key,
method: "PUT",
expiryInSeconds: 3600,
})
return {
uploadUrl: url,
publicUrl: `https://r2.tanstackship.com/${key}`,
}
}
)
// Client — direkter Upload nach 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!")
}
}
Wann welche Methode verwendet werden sollte
| Methode | Dateigröße | Anwendungsfall | Vorteile | Nachteile |
|---|---|---|---|---|
| Formulardaten via Worker | < 5 MB | Profilbilder, Dokumente | Einfach, Validierung im Worker | Worker-Ausführungszeit |
| Presigned URL | < 100 MB | Medien-Uploads, Backups | Kein Worker-Overhead, schnell | Zusätzlicher Roundtrip für URL |
| Chunked Upload | > 100 MB | Videos, Datensätze | Pausieren/Fortsetzen, Fortschritt | Komplexe Implementierung |
Chunked Upload für große Dateien
// 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}`
// Presigned URLs für jeden Chunk generieren
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 } }) => {
// Upload in der Datenbank als abgeschlossen markieren
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 mit Fortschrittsanzeige
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 } })
}
Bildverarbeitungspipeline
Kombinieren Sie R2 mit Cloudflare Image Resizing oder einer Workers-basierten Pipeline:
// server/images.ts
export const processAndUploadImage = createServerFn({ method: "POST" }).handler(
async ({ data, context }) => {
const { buffer, filename } = data
// Mehrere Größen generieren
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 }
}
)
Zugriffskontrolle
Signierte URLs für private Inhalte
export const getSignedDownloadUrl = createServerFn({ method: "GET" }).handler(
async ({ data, context }: { data: { key: string } }) => {
// Berechtigung prüfen
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")
}
// Temporäre Download-URL generieren
return await context.env.MY_BUCKET.createPresignedUrl({
key: data.key,
method: "GET",
expiryInSeconds: 3600,
})
}
)
Datei-Validierungs-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
}
Kostenanalyse: R2 vs. S3 vs. Local Storage
| Speicheranbieter | Speicher (1TB) | Egress (1TB) | A-Operationen (10M) | B-Operationen (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 |
Die fehlenden Egress-Gebühren von R2 sind ein wesentlicher Vorteil für SaaS-Anwendungen, bei denen Benutzer häufig auf hochgeladene Dateien zugreifen.
Sicherheitscheckliste für die Produktion
- Dateitypvalidierung sowohl auf Client- als auch auf Serverseite
- Erzwungene Dateigrößenbeschränkungen – Zurückweisen übergroßer Uploads vor der Verarbeitung
- Eindeutige Dateinamen mithilfe von UUIDs zur Vermeidung von Path Traversal
- Explizit gesetzte Content-Type-Header bei R2-Objekten
- Presigned URLs mit kurzer Ablaufzeit (max. 1 Stunde für PUT, 1 Tag für GET)
- Autorisierungsprüfung vor dem Generieren von Download-URLs
- Upload-Ratenbegrenzung pro Benutzer (zur Vermeidung von Missbrauch)
- Virenscan für Dokumenten-Uploads (WebAssembly-basiertes ClamAV auf Workers)
- CORS-Konfiguration im R2-Bucket schränkt Ursprünge ein
- Audit-Protokollierung aller Upload- und Download-Ereignisse
Fazit
Die Datei-Upload-Architektur ist für jede SaaS-Anwendung ein kritischer Aspekt. TanStack Start + Cloudflare R2 bietet eine skalierbare, kostengünstige Grundlage, die alles von kleinen Profilbildern bis hin zu mehrere Gigabyte großen Mediendateien verarbeitet.
Die wichtigsten architektonischen Entscheidungen:
- Kleine Dateien (< 5 MB) – Upload über Worker zur Inline-Validierung
- Mittlere Dateien (5-100 MB) – Presigned URLs für direkte Uploads zu R2
- Große Dateien (> 100 MB) – Chunked Upload mit Fortschrittsverfolgung
- Private Dateien – Signierte URLs mit Autorisierungsprüfungen
- Bilder – Verarbeitungspipeline mit mehreren Größenvarianten
Weitere Informationen zur R2-Konfiguration und Kostenoptimierung finden Sie im Cloudflare R2 Object Storage Guide. Um Bilder nach dem Upload zu optimieren und zu transformieren, lesen Sie Image Optimization for Modern Web Applications.