TurboJet
Storage

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
  1. Authenticated client calls GET /api/storage/upload with filename, content type, and optional size.
  2. API creates a Prisma File with status PENDING at users/{userId}/{uuid}.{ext} and returns a presigned PUT url plus fileId.
  3. Client PUTs the file bytes to that URL with the matching Content-Type.
  4. Client calls POST /api/storage/confirm with { fileId } so the row becomes READY.

Routes

Method / pathAuthRate limitInput
GET /api/storage/uploadRequiredWRITEQuery: filename, contentType, optional size
POST /api/storage/confirmRequiredWRITEJSON: { 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 size is 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.

On this page