Uploads (UploadThing)
A thin FileStorage port, a single catch-all route, and a disabled state that doesn't 500.
Uploads are optional. When UPLOADTHING_TOKEN is set, avatars and image uploads go through UploadThing's signed-URL flow; when it isn't, the feature is disabled at boot and the upload routes return a stable 501 the web client can render as a disabled-state.
The port
apps/api/src/uploads/application/file-storage.ts
export interface FileStoragePolicy { readonly allowedMimeTypes: readonly string[]; readonly maxFileSizeBytes: number; readonly maxFilesPerUpload: number;}
export type FileStorageRouteHandler = (request: Request) => Promise<Response>;
export interface FileStorage { readonly policy: FileStoragePolicy; routeHandler(): FileStorageRouteHandler; delete(storageKey: string): Promise<void>;}Three things live on the port: the policy (content-type allowlist, size caps, per-request count), a catch-all route handler the API mounts blindly, and a delete for garbage collection. Most provider-specific logic lives inside the handler — the API just proxies.
The default policy
apps/api/src/composition.ts
uploads: { uploadthingToken: process.env.UPLOADTHING_TOKEN ?? null, policy: { allowedMimeTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"], maxFileSizeBytes: 16 * 1024 * 1024, maxFilesPerUpload: 1, },}16 MiB, PNG/JPEG/GIF/WebP, one file per request. Tuned for avatars and inline images. When you need video or wider file-type support, change the policy here — both the UploadThing router and the API's bound is driven by the same values.
The two implementations
UploadthingFileStorage
Wraps the UploadThing SDK. At construction it builds a router whose middleware calls a UploadSessionResolver — the API's better-auth session — to stamp each upload with a user id:
apps/api/src/uploads/infrastructure/uploadthing-file-storage.ts
function buildRouter(resolveSession, policy): FileRouter { const f = createUploadthing(); const maxFileSize = formatSize(policy.maxFileSizeBytes) as "16MB"; return { image: f({ image: { maxFileSize, maxFileCount: policy.maxFilesPerUpload } }) .middleware(async ({ req }) => { const userId = await resolveSession(req); if (!userId) throw new UploadThingError({ code: "FORBIDDEN", message: "sign in to upload", }); return { userId }; }) .onUploadComplete(async ({ metadata, file }) => ({ uploadedBy: metadata.userId, key: file.key, name: file.name, size: file.size, type: file.type, })), };}onUploadComplete's return value lands on the client as the upload's metadata — that's what the web app persists as the user's avatar key.
NoopFileStorage (disabled)
routeHandler(): FileStorageRouteHandler { return async () => new Response( JSON.stringify({ error: { code: "uploads.not_configured", message: "file uploads are disabled: set UPLOADTHING_TOKEN to enable", }, }), { status: 501, headers: { "content-type": "application/json" } }, );}The disabled-state response has a stable error code, not a random 500. The web client checks for uploads.not_configured and hides the upload affordance rather than showing a scary error.
The HTTP surface
apps/api/src/interfaces/http/controllers/uploads.controller.ts
uploads.all("/*", async (c) => { const container = c.get("container"); const handler = container.fileStorage.routeHandler(); return handler(c.req.raw);});The controller is deliberately dumb: any method, any sub-path under /v1/uploads, forward the raw Request to the provider's handler. The provider owns the protocol. Swapping to S3 + pre-signed URLs would mean implementing a new adapter; the controller stays identical.
The client side
The web app uses UploadThing's React hooks, pointed at the same /v1/uploads endpoint via VITE_API_URL:
import { useUploadThing } from "~/lib/uploads/client";
function AvatarUploader() { const { startUpload, isUploading } = useUploadThing("image", { onClientUploadComplete: ([file]) => { if (!file) return; saveAvatar({ key: file.key }); // call the app API to persist }, }); // ...}The upload itself goes directly to UploadThing's CDN, not through the API — which is why it stays snappy for big files. Only the metadata callback (onUploadComplete) lands in the API, over HMAC-signed callback. Don't put anything secret in the upload response.
Deleting files
fileStorage.delete(storageKey) hits UploadThing's delete endpoint and returns void. The adapter treats 4xx on already-gone files as success — garbage collection is idempotent. Call it from projectors that listen for AvatarReplaced or UserDeleted.
Swapping to S3 / R2 / GCS
- Implement
FileStorageinuploads/infrastructure/s3-file-storage.ts—routeHandler()returns a handler that generates pre-signed PUT URLs from an initial POST, and serves completions on a second route. - Extend
buildFileStorage()to pick the new adapter based on your own env var (e.g.UPLOADS_PROVIDER). - Adjust the web-side hooks — UploadThing's React client is UploadThing-specific, so you'd swap to a provider-matching hook or a plain
fetch-based uploader.
The domain never changes — the app still speaks in storage keys and policies. Adapters own the transport.