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
StorageProviderabstraction 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
| File | Role |
|---|---|
src/server/app/uploads/uploadsService.ts | Validation, key generation, ownership, complete |
src/server/app/uploads/storage/StorageProvider.ts | The provider interface |
src/server/app/uploads/storage/r2StorageProvider.ts | Cloudflare R2 (S3-compatible) provider |
src/server/app/uploads/storage/localStorageProvider.ts | Dev-only local provider |
src/app/api/app/v1/uploads/local/route.ts | Dev-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:
| Value | Behavior |
|---|---|
local (default) | Writes under public/ via the dev-only /uploads/local route. Refused in production with a clear error. |
r2 | Cloudflare 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.comR2_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.
