File Uploads & Storage

Upload images to durable Cloudflare R2 and serve them through the Worker.

Storage is Integrated with Cloudflare R2: authenticated uploads are written to the UPLOADS binding and returned through a Worker read route. Unlike a local uploads directory, R2 objects survive application builds and Worker deployments. The included access policy is still an example that needs product hardening.

Request flow

POST /api/storage/upload-image
  → verify Better Auth session
  → parse FormData entries named "files"
  → validate count, MIME prefix, and size
  → env.UPLOADS.put(generatedKey, body, httpMetadata)
  → { success: true, data: { urls: ["/api/storage/<key>"] } }

GET /api/storage/<key>
  → env.UPLOADS.get(key)
  → stream body with stored HTTP metadata and ETag
FileRole
src/routes/api/storage/upload-image.tsSession check, multipart parsing, validation response, upload response
src/lib/storage/image-upload.tsLimits, MIME/extension mapping, generated keys, R2 writes, returned URL paths
src/routes/api/storage/$key.tsR2 lookup and Worker-proxied object response
wrangler.jsoncUPLOADS R2 binding and bucket name
src/lib/storage/image-upload.test.tsValidation and write behavior tests

The current limits are five files per request and 5 MB per file. Keys use crypto.randomUUID() plus a derived extension, so user filenames do not become object paths. The upload preserves the declared content type as R2 HTTP metadata. Returned URLs are relative application paths, not direct bucket URLs.

Configure R2

Create the production bucket

pnpm wrangler r2 bucket create soar-tanstack-uploads

Use your own name if desired.

Bind it to the Worker

The application code expects the binding name UPLOADS:

"r2_buckets": [
	{ "binding": "UPLOADS", "bucket_name": "soar-tanstack-uploads" }
]

UPLOADS is a binding, not a secret or URL. image-upload.ts and $key.ts import env from cloudflare:workers and read env.UPLOADS during the request. Do not initialize an R2 handle at module load.

Run locally

pnpm dev uses the Cloudflare Vite plugin and Miniflare-backed local R2 state. Exercise uploads through the application rather than creating a local public/uploads fallback; that would test a different storage model.

Access policy

Creation requires a valid session, but GET /api/storage/$key is currently public to anyone who knows the key. This is appropriate only for public product images. For private objects, store ownership/visibility in D1 and authorize each read, or return short-lived signed access through a dedicated policy layer.

Worker proxying keeps URLs on the application origin and gives you a place to authorize requests, but every read consumes Worker resources. For public, high-volume assets, consider an R2 custom domain/CDN and deliberate cache headers. Keep the Worker route for content that needs application policy.

Validation gaps to close

The current validator checks file.type.startsWith("image/") and size; it does not inspect magic bytes, decode the image, validate dimensions, remove metadata, or restrict every accepted MIME type to the extension map. A client can spoof multipart metadata. Add server-side content inspection before treating uploads as hardened.

Production policy should also cover:

  • per-user storage quota, total request size, and upload rate limits;
  • object ownership, read/delete/replace authorization, and audit data;
  • collision-safe prefixes or tenant/user namespaces where useful;
  • abandoned upload cleanup, retention, deletion, and backup expectations;
  • cache headers, content disposition, CSP/content sniffing, and malware checks;
  • cost monitoring and abuse response.

Verify

  • A logged-out upload returns 401.
  • A valid image below 5 MB returns /api/storage/<generated-key> and is readable.
  • Missing files, more than five files, non-image MIME, and oversized files return 4xx.
  • Unknown keys return 404; responses preserve content type and ETag.
  • Objects remain available after rebuilding/redeploying the Worker.
  • Private-object tests reject unauthorized reads after you add access policy.
  • Signature/dimension tests reject content that only claims to be an image.

On this page