Upload files
Presigned upload URLs, confirm, and a client sketch for /api/storage.
Before uploading, finish Setup. Uploads use presigned URLs so the file never passes through your serverless function body.
Flow
sequenceDiagram
participant Client
participant Api as ApiStorage
participant R2 as S3OrR2
Client->>Api: GET /api/storage/upload
Api->>Api: Create File PENDING
Api-->>Client: url plus fileId
Client->>R2: PUT file to presigned url
Client->>Api: POST /api/storage/confirm
Api->>Api: File READY- Authenticated client calls
GET /api/storage/uploadwith filename, content type, and optional size. - API creates a Prisma
Filewith statusPENDINGatusers/{userId}/{uuid}.{ext}and returns a presigned PUTurlplusfileId. - Client
PUTs the file bytes to that URL with the matchingContent-Type. - Client calls
POST /api/storage/confirmwith{ fileId }so the row becomesREADY.
Routes
| Method / path | Auth | Rate limit | Input |
|---|---|---|---|
GET /api/storage/upload | Required | WRITE | Query: filename, contentType, optional size |
POST /api/storage/confirm | Required | WRITE | JSON: { fileId } |
Validation lives in packages/validations/src/storage/storage.ts:
- Allowed types:
image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain - Max size: 10 MB (when
sizeis sent)
Router: packages/api/src/modules/storage/router.ts. Service: packages/api/src/modules/storage/service.ts.
Client sketch
async function uploadFile(file: File) {
const params = new URLSearchParams({
filename: file.name,
contentType: file.type,
size: String(file.size),
});
const prepare = await fetch(`/api/storage/upload?${params}`, {
credentials: "include",
});
if (!prepare.ok) throw new Error("Failed to get upload URL");
const { url, fileId } = (await prepare.json()) as {
url: string;
fileId: string;
};
const put = await fetch(url, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!put.ok) throw new Error("Failed to upload file");
const confirm = await fetch("/api/storage/confirm", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileId }),
});
if (!confirm.ok) throw new Error("Failed to confirm upload");
return confirm.json();
}Use a file input or dropzone of your choice, then call uploadFile. Session cookies must be sent (credentials: "include") so Better Auth can authenticate the request.
Tighten rules
Change allowed types or the 10 MB cap in packages/validations/src/storage/storage.ts. Path ownership is already enforced by generating users/{userId}/... on the server — clients do not choose the object key.
Next
Learn how to access stored files with signed or public URLs.