Recipe: Add S3-compatible uploads
Recipes
Add direct-to-bucket file uploads from the browser with S3 presigned URLs. File bytes stay off your API servers.
60 min
Estimated duration
S3 API
Protocol
R2 / S3
Backends
Let an authenticated user upload a file straight from the browser into an S3-compatible bucket. The bytes never touch the API host; the API only signs a short-lived PUT URL and records the resulting object in Postgres.
Prereqs
Section titled “Prereqs”- A working local stack.
- A bucket with S3-compatible credentials. Recommended: Cloudflare R2, which has zero egress fees and the same auth model as S3. AWS S3, Backblaze B2, and a local MinIO container also work.
- The bucket configured with a CORS rule that allows your origin to PUT.
-
Add bucket credentials to
compose/.env:Terminal window S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com # or AWS region URLS3_REGION=auto # or us-east-1 etc.S3_BUCKET=your-bucket-nameS3_ACCESS_KEY_ID=...S3_SECRET_ACCESS_KEY=...S3_PUBLIC_BASE_URL=https://files.example.com # CDN-fronted public URL, optionalAdd them to
src/config/env.schema.tsso the env validator refuses to boot if they’re missing in prod. -
Add the AWS SDK to the API:
Terminal window cd apps/api && bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner -
Create a new feature folder. Use the scaffold so the layer split is correct from the start:
Terminal window cd apps/api && bun run new:resource uploadsThat writes
uploads.routes.ts,uploads.service.ts,uploads.types.tsand wires the routes intoconfig/routes.ts. -
In
uploads.service.ts, sign a PUT URL keyed byaccountId + uuid. Return{ uploadUrl, objectKey, publicUrl }. Use@aws-sdk/s3-request-presignergetSignedUrl(...)with a 5-minute TTL. -
Add a
mediatable to the schema. Minimum columns:id,account_id,object_key,mime_type,size_bytes,created_at. After a successful PUT, the UI POSTs afinalizerequest to the API which inserts the row inside a transaction. -
UI side. The UI app’s
lib/apiclient already has the typed routes (regenerate withbun run generate:api). A simple file picker:const { uploadUrl, objectKey, publicUrl } =await apiClient.POST("/api/uploads/sign", { body: { mime, size } });await fetch(uploadUrl, { method: "PUT", body: file });await apiClient.POST("/api/uploads/finalize", { body: { objectKey } });
Verify
Section titled “Verify”-
Browser DevTools network tab: the PUT goes straight to your bucket origin, not to your API host.
-
The bucket dashboard shows the new object under
<accountId>/<uuid>(or whatever key pattern you chose). -
A row appears in the
mediatable:SELECT id, account_id, object_key, mime_type FROM media ORDER BY created_at DESC LIMIT 5; -
The audit log shows
media.upload_finalized.
What changes in code
Section titled “What changes in code”apps/api/src/config/env.schema.ts: add the six S3 env vars to the TypeBox env shape.apps/api/src/api/uploads/: new feature folder, follows the route/service/types split.apps/api/src/clients/postgres/schema/media.ts: new Drizzle table.apps/api/drizzle/0xxx_add_media_table.sql: generated migration (bun run db:generate).apps/ui/src/features/<wherever-you-need-uploads>/: picker + progress UI calling the typed client.
Related
Section titled “Related”- Env validator; how to declare the bucket vars cleanly.
- API overview; the per-feature folder shape this recipe follows.
- Multi-tenant model; scoping uploads by
accountId. - Audit log; recording upload lifecycle events.