Orbit
05 · Integrations

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" } },
);
}
Note

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
},
});
// ...
}
Heads up

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

  1. Implement FileStorage in uploads/infrastructure/s3-file-storage.tsrouteHandler() returns a handler that generates pre-signed PUT URLs from an initial POST, and serves completions on a second route.
  2. Extend buildFileStorage() to pick the new adapter based on your own env var (e.g. UPLOADS_PROVIDER).
  3. 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.