Object Storage & Direct Uploads

Presigned direct uploads to Cloudflare R2, with a local provider for development.

The App API uploads flow is integrated with real object storage: the client asks the server for a presigned URL, uploads the bytes straight to storage, then confirms. File contents never pass through the Next.js process, and production files live in Cloudflare R2 — not on the app server's disk.

What it provides

  • A StorageProvider abstraction with two implementations: R2 (production) and local disk (development only).
  • POST /api/app/v1/uploads — validates type/size, then signs a direct upload to a server-generated object key.
  • POST /api/app/v1/uploads/complete — confirms the object exists, enforces the size cap, and returns the usable URL. Idempotent.
  • Per-user key prefixes for ownership checks and account-deletion cleanup.

Important files

FileRole
src/server/app/uploads/uploadsService.tsValidation, key generation, ownership, complete
src/server/app/uploads/storage/StorageProvider.tsThe provider interface
src/server/app/uploads/storage/r2StorageProvider.tsCloudflare R2 (S3-compatible) provider
src/server/app/uploads/storage/localStorageProvider.tsDev-only local provider
src/app/api/app/v1/uploads/local/route.tsDev-only presigned-PUT target (404 in production)

The flow

Request a presigned URL

The client sends contentType, size, and an optional filename. The server checks the MIME type against the allow-list (jpeg/png/webp/gif, for profile images) and the 5 MB size cap, then generates an unpredictable object key under uploads/<userId>/… — the client can never choose the key.

Upload directly to storage

The response contains uploadUrl, method, and the headers the client must echo. The R2 presigned PUT binds Content-Type and Content-Length into the signature, so uploading a different type or a larger object than declared fails at the storage layer. URLs expire after 5 minutes.

Confirm with complete

POST /uploads/complete with the objectKey. The server verifies the key belongs to the current user (prefix check — foreign keys get a 404, never an existence hint), stats the object, deletes it if it somehow exceeds the cap, and returns the public URL. Only confirmed objects become visible to the business logic.

Choosing a provider

STORAGE_PROVIDER selects the implementation — and there is no silent fallback:

ValueBehavior
local (default)Writes under public/ via the dev-only /uploads/local route. Refused in production with a clear error.
r2Cloudflare R2. Missing R2_* values throw a configuration error at first use.

R2 configuration

STORAGE_PROVIDER=r2
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=
R2_PUBLIC_BASE_URL=https://cdn.example.com

R2_PUBLIC_BASE_URL is the public base (custom domain or r2.dev) used to build object URLs. Create the bucket, an API token with object read/write for it, and a public custom domain in the Cloudflare dashboard.

Orphan cleanup is a bucket lifecycle rule. An upload that is presigned but never confirmed leaves an unreferenced object. Configure an R2 lifecycle rule (e.g. abort/delete objects under uploads/ a few days after creation that your application never confirmed) rather than adding a cleanup job to the app. Account deletion removes all of a user's objects by prefix — see Account Deletion.

The older web route POST /api/storage/upload-image (see File Uploads & Storage) still writes to local disk and remains a dev-grade integration point. For durable uploads, use this App API flow — it is the one wired to R2.

On this page