I was building a wedding media wall where guests could upload photos and videos during the event. That sounds simple until you think about the traffic pattern: dozens of phones, large camera files, weak mobile networks, and a serverless Next.js app that should not become the file pipe for every upload.
The first instinct is usually to upload through the app. Send the file to a Next.js route, let Payload CMS receive it, and use a storage adapter to push it somewhere else. That works for small admin uploads. It is the wrong shape for bursty guest media.
The pattern I ended up using is different:
The app mints a short-lived Backblaze B2 upload URL.
The browser uploads the file bytes directly to B2.
The app finalizes the uploaded object in Payload CMS as metadata.
A Payload Job processes image derivatives later.
Payload never streams guest file bytes. It stores the catalog entry, ownership, tenant, processing status, and derivative references.
That distinction is the whole guide.
This is also not an AWS S3 presigned PUT tutorial. Backblaze B2 has an S3-compatible API, but the implementation here uses the native B2 API: b2_authorize_account, b2_get_upload_url, b2_upload_file, and b2_get_download_authorization.
The Architecture
Here is the flow I want you to keep in your head before we touch code:
text
Guest browser
|
| 1. POST /api/upload/credentials { filename }
v
Next.js route handler
- authenticates the user/session
- resolves the active tenant
- asks B2 for an upload URL
|
| returns { uploadUrl, authorizationToken, filePath }
v
Guest browser
|
| 2. POST file bytes directly to Backblaze B2
| with Authorization, X-Bz-File-Name, X-Bz-Content-Sha1
v
Backblaze B2
|
| returns { fileId, fileName }
v
Guest browser
|
| 3. POST /api/feed with media metadata
v
Payload CMS
- creates media documents
- creates one feed entry
- queues image processing jobs
This design keeps the hot path small. Your app server mints credentials and writes database records. It does not receive a 200 MB video body. It does not buffer ten images into memory. It does not ask Payload's upload pipeline to become your public upload gateway.
That object key gives you three useful properties:
tenant isolation at the storage layout level
date grouping for operational cleanup/debugging
collision resistance without hiding the original filename completely
What You Need
Install the packages used by this implementation:
bash
pnpm add axios crypto-js sharp uuid
You also need these environment variables:
bash
B2_APPLICATION_KEY_ID=
B2_APPLICATION_KEY=
B2_BUCKET_ID=
B2_BUCKET_NAME=
# Optional pull-zone or CDN URL for public delivery.
CDN_BASE_URL=
I am intentionally not including B2_DOWNLOAD_URL in the required runtime list. Backblaze returns the correct downloadUrl from b2_authorize_account, and the implementation below uses that value. You can document a download URL for humans if you want, but do not build runtime code that depends on a stale hardcoded B2 download host.
Configure CORS On The B2 Bucket
Direct browser uploads will fail before your route code matters if the bucket CORS rules are wrong.
The browser is going to send custom headers like X-Bz-File-Name and X-Bz-Content-Sha1 to Backblaze. Your B2 bucket needs to allow that upload operation from the browser.
For local development you might temporarily use allowedOrigins: ["*"], but for production use your real app origins. The critical operation is b2_upload_file. Without it, the direct upload request may look fine in your app code and still die at the browser boundary.
Build The Server-Side B2 Client
Now create the B2 utility module. This module has three jobs:
cache account authorization for a short period
mint upload URLs for the browser
support server-side download/upload for derivatives
The retry helper deliberately retries network failures only. It does not retry normal HTTP error responses because a 400 or 403 from Backblaze usually means your request is wrong, your credentials are wrong, or the bucket policy is wrong. Retrying that blindly just hides the real problem.
This is the point where the storage object key is decided. Do not let the browser decide the full path. Let the browser provide the original filename, then let the server attach the tenant prefix, date, sanitized base, and random suffix.
Resolve The Active Tenant
In my app, each wedding is a tenant. The public guest route includes the tenant slug in the URL, and API calls append it as ?tenant=slug.
The exact tenant model is not important. The important rule is that the same tenant is used in both places:
B2 object prefix: weddingSlug/...
Payload document relation: wedding: weddingId
That gives you storage isolation and database isolation.
Mint Upload Credentials In A Route Handler
In Next.js App Router, create a route.ts file for the credential endpoint. POST route handlers are a good fit here because this is a request-time mutation-like operation: the server talks to Backblaze and returns short-lived upload details.
This endpoint is small, but it is one of the most important safety boundaries in the system. Do not expose B2 application keys to the browser. Do not let unauthenticated users mint upload tokens. Do not accept a full object path from the client.
You can use fetch for many upload flows, but XHR still gives simple upload progress events in browsers. For a guest upload UI, progress matters. People need to know that the phone is still sending the file.
There is one subtle detail in the filename header:
Encode each path segment, not the entire path at once. You want special characters in filenames encoded, but you still want / to remain the path separator inside the B2 object key.
Orchestrate A Batch Sequentially
The upload UI in my app allows multiple files, but it uploads them one at a time. That is deliberate.
Parallel uploads can be faster on a perfect connection. Wedding guest uploads do not happen on perfect connections. Sequential uploads reduce congestion, make progress reporting simpler, and avoid hammering your app and B2 with many simultaneous credential and upload requests from the same phone.
The teaching point is not the wedding-specific plugin config. The teaching point is that the media document is a catalog entry:
where is the original file?
who uploaded it?
which tenant owns it?
is it ready?
where are the derivatives?
That is exactly what a CMS should own. It does not need to own the byte stream.
Finalize The Upload In Payload
Now implement the finalize route. In my app this is part of /api/feed because uploaded media becomes a feed post. In your app it might be /api/media/finalize.
The route receives B2 metadata, creates one Payload media document per uploaded file, and then creates one entries document that references those media records.
There are two different media type vocabularies in this implementation:
client/feed API: photo | video
Payload database: image | video
That mapping is small, but it is worth making explicit. If you leak both vocabularies everywhere, your UI and database code become harder to reason about.
Also notice the hard limit of ten media items. The UI should enforce it, but the API must enforce it too. Client-side limits are user experience. Server-side limits are system protection.
Process Image Derivatives With Payload Jobs
After finalization, the original object is already in B2 and the feed entry already exists. Image processing should not block the guest upload response.
The job downloads the original from B2, applies EXIF-aware rotation, creates a thumbnail and display-size WebP, uploads those derivatives back to B2, and updates the media record.
In payload.config.ts, register the task and run it on a small interval:
For a small app, Payload autoRun is enough. For heavier video processing or large traffic, I would move expensive queues to a dedicated worker process. The boundary is already there because processing is a job, not a collection hook.
Upload Derivatives Back To B2 From The Server
The job needs server-side B2 helpers for downloading originals and uploading processed files.
When it is not present, use an app route as a fallback:
text
/api/media/download/123?size=display
For guest downloads, generate a short-lived Backblaze download authorization and redirect the browser to B2 with an attachment content disposition. That keeps large downloads off your app server when the redirect succeeds.
The fallback route should still verify tenant ownership before serving anything. Do not let /api/media/download/:id become a cross-tenant object proxy.
Failure Modes You Should Plan For
The happy path is clean, but production upload systems live in the edges.
The first failure mode is CORS. If credentials are valid but browser upload fails before reaching B2, check the bucket CORS rules and the request headers first.
The second failure mode is stale B2 account auth. Cache the account auth token, but refresh on a 401 and retry the B2 control-plane request once.
The third failure mode is finalize failure after a successful B2 upload. In that case, the object exists in B2 but Payload does not know about it. The simplest first version is to surface an error to the user and add operational cleanup for unreferenced objects later. If uploads are business-critical, add an upload-sessions table and a reconciliation job.
The fourth failure mode is derivative processing failure. That should not make the original upload disappear. Mark the media record failed, store the failure reason, and keep enough metadata to retry.
The fifth failure mode is accidental platform overload. Enforce max file count in both UI and API. In my app the limit is ten files per post. The UI truncates selection to ten; the API rejects anything larger.
What I Deliberately Did Not Use
This implementation does not use Payload's upload: true collection behavior for guest uploads.
Payload upload adapters are useful when your CMS is the upload interface, especially for admin-authenticated editorial files. They are not the best fit when a public browser session needs to upload many large files quickly.
This implementation also does not use Vercel Blob. Vercel Blob has its own upload completion model. The pattern here is app-owned finalization: the browser uploads to B2, then the browser tells your app exactly which B2 objects to register.
Finally, this is not an AWS S3 presigned URL implementation. Backblaze B2 has S3-compatible features, but this code uses the native B2 upload URL API. That is why the browser upload is a POST to uploadUrl with X-Bz-File-Name and X-Bz-Content-Sha1.
Drop-Into-Agent Implementation Brief
If you want an AI agent to implement this pattern in your own codebase, give it this brief:
text
Build direct browser uploads to Backblaze B2 with Payload CMS finalization.
Use this architecture:
1. POST /api/upload/credentials with { filename }.
2. Authenticate the request and resolve the active tenant.
3. Call Backblaze b2_get_upload_url server-side.
4. Return { uploadUrl, authorizationToken, filePath }.
5. In the browser, calculate SHA1 and POST the file directly to the B2 upload URL.
6. Send Authorization, X-Bz-File-Name, X-Bz-Content-Sha1, and Content-Type headers.
7. After B2 returns fileId/fileName, POST metadata to /api/feed or /api/media/finalize.
8. Create Payload media docs that store provider, bucket, objectKey, fileId, filename, MIME type, size, status, and tenant.
9. Create the domain record that references those media docs.
10. Queue a Payload Job for image derivatives.
Do not stream guest file bytes through Next.js or Payload.
Do not use Payload upload adapters for the public guest path.
Do not present this as AWS S3 presigned PUT; it is Backblaze B2 native upload URLs.
Enforce max files on both client and server.
Configure B2 CORS for b2_upload_file and custom headers.
Final Takeaway
The robust part of this system is not one clever function. It is the separation of responsibilities.
Backblaze B2 stores bytes. The browser sends bytes directly there. Next.js mints credentials and finalizes metadata. Payload CMS owns the media catalog, tenant relationship, feed entry, and processing state. Payload Jobs handle expensive derivative work after the upload is already complete.
That is the architecture I now reach for when I need public users to upload a lot of media quickly without turning the app server into the bottleneck.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija
// File: scripts/configure-b2-cors.ts
import
from
"axios"
const
env
B2_APPLICATION_KEY_ID
const
env
B2_APPLICATION_KEY
const
env
B2_BUCKET_ID
if
throw
new
Error
"Missing B2_APPLICATION_KEY_ID, B2_APPLICATION_KEY, or B2_BUCKET_ID."