Store uploaded media on any S3-compatible storage (AWS S3, Cloudflare R2, Scaleway, SeaweedFS, MinIO)
Uploaded media are stored on an S3-compatible bucket. The upload system uses an adapter pattern, so swapping the provider never changes application code.
The contract lives in src/lib/files/upload-file.ts:
export type UploadFileAdapter = {
uploadFile: (params: {
file: File;
path: string;
}) => Promise<
{ error: null; data: { url: string } } | { error: Error; data: null }
>;
uploadFiles: (
params: { file: File; path: string }[],
) => Promise<{ error: Error | null; data: { url: string } | null }[]>;
};
The active adapter is src/lib/files/s3-adapter.ts, imported in src/features/images/upload-image.action.ts.
Add the credentials to your .env:
S3_ENDPOINT="https://s3.example.com" # or https://<account-id>.r2.cloudflarestorage.com
S3_BUCKET="my-bucket"
S3_ACCESS_KEY="..."
S3_SECRET_KEY="..."
# Optional — only when the provider requires a specific region
# S3_REGION="auto" # Cloudflare R2
These are validated in src/lib/env.ts and are required: the app refuses to boot without them.
S3_REGION="auto" is mandatory, otherwise every request fails with SignatureDoesNotMatch.S3_REGION to the bucket region.us-east-1 works.The client uses forcePathStyle, so public URLs are ${S3_ENDPOINT}/${S3_BUCKET}/${key}. The bucket (or the relevant prefix) must allow public reads for the returned URLs to be displayable.
Keys are built from the path passed by the caller plus a nanoid prefix, so two uploads of photo.png never overwrite each other:
orgs/{orgId}/V1StGXR8Z5-photo.png
users/{userId}/V1StGXR8Z5-avatar.png
File names coming from the client are sanitized (only a-zA-Z0-9._- survives), which keeps an upload from escaping its prefix.
Never call the adapter from a component — go through the server actions, which validate the file (images only, max 2 MB) and scope the path to the current user or organization:
import {
uploadOrgImageAction,
uploadUserImageAction,
} from "@/features/images/upload-image.action";
Create an adapter implementing UploadFileAdapter (for example src/lib/files/uploadthing-adapter.ts) and change the import in src/features/images/upload-image.action.ts:
import { fileAdapter } from "@/lib/files/s3-adapter";
Image upload is gated by a feature flag. In src/site-config.ts:
export const SiteConfig = {
// ...
features: {
enableImageUpload: true,
// ...
},
};
This enables drag-and-drop and click-to-upload throughout the app.