BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Backblaze B2 Direct Uploads With Payload CMS Jobs

Backblaze B2 Direct Uploads With Payload CMS Jobs

Bypass Next.js upload limits by sending files from the browser to Backblaze B2, then finalize metadata in Payload CMS.

7th July 2026·Updated on:18th July 2026··
Payload
Backblaze B2 Direct Uploads With Payload CMS Jobs

Evaluating Payload CMS Implementation Costs?

Scope design, content structure, and migration hours to estimate a realistic production timeline and hosting setup.

Try the Cost EstimatorGet a Second Opinion

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • The Architecture
  • What You Need
  • Configure CORS On The B2 Bucket
  • Build The Server-Side B2 Client
  • Resolve The Active Tenant
  • Mint Upload Credentials In A Route Handler
  • Upload From The Browser With SHA1 And Progress
  • Orchestrate A Batch Sequentially
  • Add A Reference-Only Media Collection
  • Finalize The Upload In Payload
  • Process Image Derivatives With Payload Jobs
  • Upload Derivatives Back To B2 From The Server
  • Deliver Through A CDN, Fall Back To The App
  • Failure Modes You Should Plan For
  • What I Deliberately Did Not Use
  • Drop-Into-Agent Implementation Brief
  • Final Takeaway
On this page:
  • The Architecture
  • What You Need
  • Configure CORS On The B2 Bucket
  • Build The Server-Side B2 Client
  • Resolve The Active Tenant
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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:

  1. The app mints a short-lived Backblaze B2 upload URL.
  2. The browser uploads the file bytes directly to B2.
  3. The app finalizes the uploaded object in Payload CMS as metadata.
  4. 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.

For my wedding app, object keys are tenant-aware:

text
{weddingSlug}/{YYYY-MM-DD}/{sanitizedBase}-{8hex}{ext}

Example:

text
sara-nej/2026-07-10/ceremony-7a91c02f.jpg

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.

Create a script like this:

ts
// File: scripts/configure-b2-cors.ts
import axios from "axios";

const appKeyId = process.env.B2_APPLICATION_KEY_ID;
const appKey = process.env.B2_APPLICATION_KEY;
const bucketId = process.env.B2_BUCKET_ID;

if (!appKeyId || !appKey || !bucketId) {
  throw new Error("Missing B2_APPLICATION_KEY_ID, B2_APPLICATION_KEY, or B2_BUCKET_ID.");
}

async function main() {
  const credentials = Buffer.from(`${appKeyId}:${appKey}`).toString("base64");

  const authResponse = await axios.get(
    "https://api.backblazeb2.com/b2api/v3/b2_authorize_account",
    {
      headers: {
        Authorization: `Basic ${credentials}`,
      },
    },
  );

  const { authorizationToken, accountId, apiInfo } = authResponse.data;
  const apiUrl = apiInfo.storageApi.apiUrl;

  const corsRules = [
    {
      corsRuleName: "allow-browser-uploads",
      allowedOrigins: ["https://your-domain.com"],
      allowedHeaders: ["*"],
      allowedOperations: [
        "b2_upload_file",
        "b2_download_file_by_id",
        "b2_download_file_by_name",
      ],
      exposeHeaders: ["x-bz-content-sha1", "x-bz-file-name"],
      maxAgeSeconds: 3600,
    },
  ];

  await axios.post(
    `${apiUrl}/b2api/v3/b2_update_bucket`,
    {
      accountId,
      bucketId,
      corsRules,
    },
    {
      headers: {
        Authorization: authorizationToken,
      },
    },
  );

  console.log("Backblaze B2 CORS rules updated.");
}

main().catch((error) => {
  console.error(error.response?.data || error);
  process.exit(1);
});

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

Start with the auth cache and retry helper.

ts
// File: lib/backblaze.ts
import axios from "axios";
import crypto from "crypto";
import https from "https";
import { v4 as uuidv4 } from "uuid";

const appKeyId = process.env.B2_APPLICATION_KEY_ID;
const appKey = process.env.B2_APPLICATION_KEY;
const bucketId = process.env.B2_BUCKET_ID;
const bucketName = process.env.B2_BUCKET_NAME;

const B2_API_URL = "https://api.backblazeb2.com/b2api/v3";

const b2Client = axios.create({
  timeout: 15000,
  httpsAgent: new https.Agent({
    keepAlive: true,
    keepAliveMsecs: 3000,
    maxSockets: 25,
  }),
});

type AuthCache = {
  authorizationToken: string;
  apiUrl: string;
  downloadUrl: string;
};

export type BackblazeUploadCredentials = {
  uploadUrl: string;
  authorizationToken: string;
  filePath: string;
};

let authCache: AuthCache | null = null;
let authExpiration = 0;
const AUTH_CACHE_TTL = 3600 * 1000;

async function withRetry<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
  try {
    return await fn();
  } catch (error) {
    const err = error as { code?: string; response?: unknown };
    const retryableCodes = ["ECONNRESET", "ETIMEDOUT", "ECONNABORTED", "ENETUNREACH"];

    if (retries <= 0 || err.response || !retryableCodes.includes(err.code || "")) {
      throw error;
    }

    await new Promise((resolve) => setTimeout(resolve, 1000));
    return withRetry(fn, retries - 1);
  }
}

async function authorizeAccount(forceRefresh = false): Promise<AuthCache> {
  if (!forceRefresh && authCache && Date.now() < authExpiration) {
    return authCache;
  }

  if (!appKeyId || !appKey) {
    throw new Error("Missing Backblaze B2 application credentials.");
  }

  const credentials = Buffer.from(`${appKeyId}:${appKey}`).toString("base64");

  const response = await withRetry(() =>
    b2Client.get<{
      authorizationToken: string;
      apiInfo: {
        storageApi: {
          apiUrl: string;
          downloadUrl: string;
        };
      };
    }>(`${B2_API_URL}/b2_authorize_account`, {
      headers: {
        Authorization: `Basic ${credentials}`,
      },
    }),
  );

  authCache = {
    authorizationToken: response.data.authorizationToken,
    apiUrl: response.data.apiInfo.storageApi.apiUrl,
    downloadUrl: response.data.apiInfo.storageApi.downloadUrl,
  };
  authExpiration = Date.now() + AUTH_CACHE_TTL;

  return authCache;
}

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.

Now add upload URL generation.

ts
// File: lib/backblaze.ts
function sanitizeB2FileName(fileName: string): string {
  const trimmed = fileName.trim().replace(/[\\/]+/g, "-");
  const sanitized = trimmed.replace(/[\x00-\x1F\x7F]+/g, "").replace(/\s+/g, " ");

  return sanitized || uuidv4();
}

export async function generateUploadURL(prefix: string, fileName = "file") {
  if (!bucketId) {
    throw new Error("Missing B2_BUCKET_ID.");
  }

  async function requestUploadUrl(forceRefresh = false) {
    const auth = await authorizeAccount(forceRefresh);
    const currentDate = new Date().toISOString().split("T")[0];
    const lastDotIndex = fileName.lastIndexOf(".");
    const fileExt = lastDotIndex > -1 ? fileName.slice(lastDotIndex) : "";
    const fileBase = lastDotIndex > -1 ? fileName.slice(0, lastDotIndex) : fileName;
    const randomSuffix = crypto.randomBytes(4).toString("hex");
    const uniqueFileName = sanitizeB2FileName(`${fileBase}-${randomSuffix}${fileExt}`);
    const filePath = `${prefix}/${currentDate}/${uniqueFileName}`;

    const response = await withRetry(() =>
      b2Client.post<{
        uploadUrl: string;
        authorizationToken: string;
      }>(
        `${auth.apiUrl}/b2api/v3/b2_get_upload_url`,
        { bucketId },
        {
          headers: {
            Authorization: auth.authorizationToken,
          },
        },
      ),
    );

    return {
      uploadUrl: response.data.uploadUrl,
      authorizationToken: response.data.authorizationToken,
      filePath,
    };
  }

  try {
    return await requestUploadUrl(false);
  } catch (error) {
    const err = error as { response?: { status?: number } };
    if (err.response?.status === 401) {
      return requestUploadUrl(true);
    }
    throw error;
  }
}

export function getBackblazeBucketName() {
  if (!bucketName) {
    throw new Error("Missing B2_BUCKET_NAME.");
  }

  return bucketName;
}

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 resolver can also support subdomains:

ts
// File: lib/tenancy.ts
import type { NextRequest } from "next/server";
import { getPayloadClient } from "@/payload/utilities/payloadClient";

export async function getActiveWeddingSlug(request: NextRequest): Promise<string> {
  const url = new URL(request.url);
  const host = request.headers.get("host") || "";

  let tenantSlug = url.searchParams.get("tenant")?.trim() || null;

  if (!tenantSlug && !host.startsWith("localhost") && !host.startsWith("127.0.0.1")) {
    const parts = host.split(".");
    if (parts.length > 2) {
      tenantSlug = parts[0];
    }
  }

  if (tenantSlug) {
    return tenantSlug;
  }

  const payload = await getPayloadClient();
  const weddings = await payload.find({
    collection: "weddings",
    limit: 1,
    depth: 0,
    overrideAccess: true,
  });

  const fallback = weddings.docs[0]?.slug;
  if (!fallback) {
    throw new Error("No wedding tenant found.");
  }

  return fallback;
}

export async function getActiveWeddingId(request: NextRequest): Promise<number> {
  const slug = await getActiveWeddingSlug(request);
  const payload = await getPayloadClient();

  const result = await payload.find({
    collection: "weddings",
    where: {
      slug: { equals: slug },
    },
    limit: 1,
    depth: 0,
    overrideAccess: true,
  });

  const wedding = result.docs[0];
  if (!wedding) {
    throw new Error(`Wedding tenant not found for slug "${slug}".`);
  }

  return wedding.id as number;
}

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.

ts
// File: app/api/upload/credentials/route.ts
import { NextRequest, NextResponse } from "next/server";
import { generateUploadURL } from "@/lib/backblaze";
import { getActiveWeddingSlug } from "@/lib/tenancy";
import { getAuthenticatedUser } from "@/utilities/auth/getAuthenticatedUser";

export async function POST(request: NextRequest) {
  try {
    const user = await getAuthenticatedUser();

    if (!user) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const body = (await request.json()) as { filename?: string };

    if (!body.filename?.trim()) {
      return NextResponse.json({ error: "filename is required" }, { status: 400 });
    }

    const weddingSlug = await getActiveWeddingSlug(request);
    const credentials = await generateUploadURL(weddingSlug, body.filename);

    return NextResponse.json(credentials);
  } catch (error) {
    console.error("Failed to generate B2 upload credentials:", error);

    return NextResponse.json(
      { error: "Failed to generate upload credentials" },
      { status: 500 },
    );
  }
}

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.

The browser only receives:

ts
type B2UploadCredentials = {
  uploadUrl: string;
  authorizationToken: string;
  filePath: string;
};

That is enough for one direct upload.

Upload From The Browser With SHA1 And Progress

Backblaze B2 browser uploads require the file content and specific headers. The X-Bz-Content-Sha1 header is the one that often trips people up.

Here is a client utility that calculates SHA1 and sends the file with XHR so you get progress events:

ts
// File: lib/upload-to-backblaze.ts
import * as CryptoJS from "crypto-js";

export type B2UploadCredentials = {
  uploadUrl: string;
  authorizationToken: string;
  filePath: string;
};

async function calculateSHA1(blob: Blob): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();

    reader.onload = (event) => {
      const binary = event.target?.result;

      if (typeof binary !== "string") {
        reject(new Error("Failed to read file."));
        return;
      }

      const hash = CryptoJS.SHA1(CryptoJS.enc.Latin1.parse(binary));
      resolve(hash.toString());
    };

    reader.onerror = () => reject(new Error("Failed to read file."));
    reader.readAsBinaryString(blob);
  });
}

export async function uploadToBackblaze(
  file: Blob,
  fileName: string,
  uploadCredentials: B2UploadCredentials,
  onProgress?: (progress: number) => void,
): Promise<{ fileId: string; filePath: string }> {
  const sha1Hash = await calculateSHA1(file);
  const filePath = uploadCredentials.filePath || fileName;
  const encodedFilePath = filePath.split("/").map(encodeURIComponent).join("/");

  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();

    xhr.upload.onprogress = (event) => {
      if (!event.lengthComputable || !onProgress) return;
      onProgress((event.loaded / event.total) * 100);
    };

    xhr.onload = () => {
      if (xhr.status < 200 || xhr.status >= 300) {
        reject(new Error(`Upload failed with status ${xhr.status}`));
        return;
      }

      try {
        const response = JSON.parse(xhr.responseText) as {
          fileId?: string;
          fileName?: string;
        };

        if (!response.fileId || !response.fileName) {
          reject(new Error("Backblaze upload response was missing file metadata."));
          return;
        }

        resolve({
          fileId: response.fileId,
          filePath: response.fileName,
        });
      } catch {
        reject(new Error("Failed to parse Backblaze upload response."));
      }
    };

    xhr.onerror = () => reject(new Error("Upload failed."));

    xhr.open("POST", uploadCredentials.uploadUrl);
    xhr.setRequestHeader("Authorization", uploadCredentials.authorizationToken);
    xhr.setRequestHeader("X-Bz-File-Name", encodedFilePath);
    xhr.setRequestHeader("X-Bz-Content-Sha1", sha1Hash);
    xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");

    xhr.send(file);
  });
}

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:

ts
const encodedFilePath = filePath.split("/").map(encodeURIComponent).join("/");

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.

ts
// File: lib/data/payloadFeedRepository.ts
import { uploadToBackblaze } from "@/lib/upload-to-backblaze";

type MediaItem = {
  mediaType: "photo" | "video";
  b2FileId: string;
  filePath: string;
  filename: string;
  mimeType: string;
  size: number;
};

function apiPath(path: string) {
  const tenant = window.location.pathname.split("/").filter(Boolean)[0];

  if (!tenant || tenant === "admin") {
    return path;
  }

  const separator = path.includes("?") ? "&" : "?";
  return `${path}${separator}tenant=${encodeURIComponent(tenant)}`;
}

export async function createMediaPost(
  input: {
    files: File[];
    caption?: string;
    guestName?: string;
  },
  onProgress?: (fileIndex: number, progress: number) => void,
) {
  const mediaItems: MediaItem[] = [];

  for (let index = 0; index < input.files.length; index++) {
    const file = input.files[index];

    const credentialsResponse = await fetch(apiPath("/api/upload/credentials"), {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        filename: file.name,
      }),
    });

    if (!credentialsResponse.ok) {
      throw new Error("Failed to get upload credentials.");
    }

    const credentials = await credentialsResponse.json();

    const uploaded = await uploadToBackblaze(
      file,
      file.name,
      credentials,
      (progress) => onProgress?.(index, progress),
    );

    mediaItems.push({
      mediaType: file.type.startsWith("video/") ? "video" : "photo",
      b2FileId: uploaded.fileId,
      filePath: uploaded.filePath,
      filename: file.name,
      mimeType: file.type,
      size: file.size,
    });
  }

  const response = await fetch(apiPath("/api/feed"), {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "media",
      mediaItems,
      caption: input.caption,
      guestName: input.guestName,
    }),
  });

  if (!response.ok) {
    throw new Error("Failed to create media post.");
  }

  return response.json();
}

Notice the shape of the finalize payload. The browser does not send bytes to Payload. It sends the result of the B2 upload:

ts
{
  mediaType: "photo",
  b2FileId: uploaded.fileId,
  filePath: uploaded.filePath,
  filename: file.name,
  mimeType: file.type,
  size: file.size,
}

At this point, B2 already has the object. Payload is about to record that it exists.

Add A Reference-Only Media Collection

This is the Payload part that matters most: the media collection is not an upload collection.

There is no upload: true here. The collection stores a reference to object storage plus processing state.

ts
// File: payload/collections/Media/index.ts
import type { CollectionConfig } from "payload";

export const Media: CollectionConfig = {
  slug: "media",
  admin: {
    useAsTitle: "id",
    defaultColumns: ["mediaType", "status", "createdAt"],
  },
  fields: [
    {
      name: "uploadedBy",
      type: "relationship",
      relationTo: "users",
      index: true,
    },
    {
      name: "mediaType",
      type: "select",
      required: true,
      options: [
        { label: "Image", value: "image" },
        { label: "Video", value: "video" },
      ],
      index: true,
    },
    {
      name: "status",
      type: "select",
      required: true,
      defaultValue: "pending",
      options: [
        { label: "Pending", value: "pending" },
        { label: "Processing", value: "processing" },
        { label: "Ready", value: "ready" },
        { label: "Failed", value: "failed" },
        { label: "Deleted", value: "deleted" },
      ],
      index: true,
    },
    {
      name: "storage",
      type: "group",
      fields: [
        { name: "provider", type: "text", defaultValue: "backblaze-b2" },
        { name: "bucket", type: "text" },
        { name: "objectKey", type: "text", required: true, unique: true, index: true },
        { name: "fileId", type: "text", index: true },
        { name: "checksum", type: "text" },
      ],
    },
    {
      name: "file",
      type: "group",
      fields: [
        { name: "originalFilename", type: "text" },
        { name: "mimeType", type: "text" },
        { name: "extension", type: "text" },
        { name: "size", type: "number" },
      ],
    },
    {
      name: "dimensions",
      type: "group",
      fields: [
        { name: "width", type: "number" },
        { name: "height", type: "number" },
        { name: "durationSeconds", type: "number" },
        { name: "orientation", type: "number" },
      ],
    },
    {
      name: "derivatives",
      type: "group",
      fields: [
        { name: "thumbnailKey", type: "text" },
        { name: "displayKey", type: "text" },
        { name: "posterKey", type: "text" },
        { name: "processedVideoKey", type: "text" },
      ],
    },
    {
      name: "processing",
      type: "group",
      fields: [
        { name: "attempts", type: "number", defaultValue: 0 },
        { name: "startedAt", type: "date" },
        { name: "completedAt", type: "date" },
        { name: "failureReason", type: "textarea" },
      ],
    },
  ],
};

In a multi-tenant Payload setup, the wedding relationship can be injected by multiTenantPlugin instead of declared directly on the collection.

ts
// File: payload.config.ts
import { multiTenantPlugin } from "@payloadcms/plugin-multi-tenant";

export default buildConfig({
  plugins: [
    multiTenantPlugin({
      tenantsSlug: "weddings",
      tenantField: {
        name: "wedding",
      },
      collections: {
        media: {},
        entries: {},
      },
    }),
  ],
});

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.

ts
// File: app/api/feed/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getBackblazeBucketName } from "@/lib/backblaze";
import { getActiveWeddingId } from "@/lib/tenancy";
import { getPayloadClient } from "@/payload/utilities/payloadClient";
import { getAuthenticatedUserForAction } from "@/utilities/auth/getAuthenticatedUser";

const MAX_MEDIA_PER_POST = 10;

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { type, guestName } = body;

    const weddingId = await getActiveWeddingId(request);
    const payload = await getPayloadClient();
    const user = await getAuthenticatedUserForAction(request.headers);

    if (type !== "media") {
      return NextResponse.json({ error: "Invalid post type" }, { status: 400 });
    }

    const mediaItems = body.mediaItems as
      | Array<{
          mediaType?: string;
          b2FileId?: string;
          filePath?: string;
          filename?: string;
          mimeType?: string;
          size?: number;
        }>
      | undefined;

    if (!Array.isArray(mediaItems) || mediaItems.length === 0) {
      return NextResponse.json({ error: "mediaItems is required" }, { status: 400 });
    }

    if (mediaItems.length > MAX_MEDIA_PER_POST) {
      return NextResponse.json(
        { error: `A post can contain at most ${MAX_MEDIA_PER_POST} media items` },
        { status: 400 },
      );
    }

    if (mediaItems.some((item) => !item.filePath)) {
      return NextResponse.json(
        { error: "filePath is required for each media item" },
        { status: 400 },
      );
    }

    const bucketName = getBackblazeBucketName();

    const mediaDocs = await Promise.all(
      mediaItems.map(async (item) => {
        const dbMediaType = item.mediaType === "video" ? "video" : "image";
        const extension = item.filename?.split(".").pop() || (dbMediaType === "video" ? "mp4" : "jpg");

        const doc = await payload.create({
          collection: "media",
          data: {
            mediaType: dbMediaType,
            status: "pending",
            uploadedBy: user?.id || undefined,
            storage: {
              provider: "backblaze-b2",
              bucket: bucketName,
              objectKey: item.filePath,
              fileId: item.b2FileId || undefined,
            },
            file: {
              originalFilename: item.filename || "upload",
              mimeType: item.mimeType || (dbMediaType === "video" ? "video/mp4" : "image/jpeg"),
              extension,
              size: item.size || 0,
            },
            wedding: weddingId,
          },
          overrideAccess: true,
        });

        if (dbMediaType === "image" && item.filePath) {
          await payload.jobs.queue({
            task: "processMedia",
            input: {
              mediaId: doc.id,
              objectKey: item.filePath,
            },
            req: request as never,
          } as never);
        }

        return doc;
      }),
    );

    const entry = await payload.create({
      collection: "entries",
      data: {
        entryType: "media",
        media: mediaDocs.map((doc) => doc.id),
        guestName: user?.fullName || guestName?.trim() || undefined,
        author: user?.id || undefined,
        status: "published",
        wedding: weddingId,
        publishedAt: new Date().toISOString(),
      },
      overrideAccess: true,
    });

    return NextResponse.json({
      id: String(entry.id),
      type: "media",
      media: mediaDocs.map((doc, index) => ({
        id: String(doc.id),
        mediaType: mediaItems[index].mediaType === "video" ? "video" : "photo",
      })),
    });
  } catch (error) {
    console.error("Failed to create media post:", error);

    return NextResponse.json(
      { error: "Failed to create media post" },
      { status: 500 },
    );
  }
}

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.

Use a Payload Job for derivatives:

ts
// File: payload/jobs/tasks/processMedia.ts
import sharp from "sharp";
import { downloadFileFromBackblaze, uploadFileToBackblaze } from "@/lib/backblaze";

export const processMediaTask = {
  slug: "processMedia",
  label: "Process Media Uploads",
  inputSchema: [
    { name: "mediaId", type: "number", required: true },
    { name: "objectKey", type: "text", required: true },
  ],
  outputSchema: [
    { name: "success", type: "checkbox" },
    { name: "skipped", type: "checkbox" },
  ],
  handler: async ({ input, req }: { input: any; req: any }) => {
    const { mediaId, objectKey } = input as { mediaId: number; objectKey: string };
    const { payload } = req;

    const media = await payload.findByID({
      collection: "media",
      id: mediaId,
      depth: 0,
      overrideAccess: true,
    });

    if (!media) {
      throw new Error(`Media document ${mediaId} not found.`);
    }

    const attempts = (media.processing?.attempts || 0) + 1;

    await payload.update({
      collection: "media",
      id: mediaId,
      data: {
        status: "processing",
        processing: {
          ...media.processing,
          attempts,
          startedAt: new Date().toISOString(),
        },
      },
      overrideAccess: true,
    });

    try {
      if (media.mediaType !== "image") {
        await payload.update({
          collection: "media",
          id: mediaId,
          data: {
            status: "ready",
            processing: {
              ...media.processing,
              attempts,
              completedAt: new Date().toISOString(),
            },
          },
          overrideAccess: true,
        });

        return { output: { skipped: true } };
      }

      const { data: originalBuffer } = await downloadFileFromBackblaze(objectKey);
      const image = sharp(originalBuffer).rotate();
      const metadata = await image.metadata();

      const thumbnail = await image
        .clone()
        .resize(300, 300, { fit: "inside", withoutEnlargement: true })
        .webp({ quality: 80 })
        .toBuffer();

      const display = await image
        .clone()
        .resize(1200, 1200, { fit: "inside", withoutEnlargement: true })
        .webp({ quality: 85 })
        .toBuffer();

      const basePath = objectKey.includes(".")
        ? objectKey.slice(0, objectKey.lastIndexOf("."))
        : objectKey;

      const thumbnailKey = `${basePath}_thumb.webp`;
      const displayKey = `${basePath}_display.webp`;

      await uploadFileToBackblaze(thumbnail, thumbnailKey, "image/webp");
      await uploadFileToBackblaze(display, displayKey, "image/webp");

      const isRotated = metadata.orientation && [5, 6, 7, 8].includes(metadata.orientation);

      await payload.update({
        collection: "media",
        id: mediaId,
        data: {
          status: "ready",
          dimensions: {
            width: isRotated ? metadata.height : metadata.width,
            height: isRotated ? metadata.width : metadata.height,
            orientation: metadata.orientation || undefined,
          },
          derivatives: {
            thumbnailKey,
            displayKey,
          },
          processing: {
            attempts,
            completedAt: new Date().toISOString(),
          },
        },
        overrideAccess: true,
      });

      return { output: { success: true } };
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);

      await payload.update({
        collection: "media",
        id: mediaId,
        data: {
          status: "failed",
          processing: {
            attempts,
            completedAt: new Date().toISOString(),
            failureReason: message,
          },
        },
        overrideAccess: true,
      });

      throw error;
    }
  },
};

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:

ts
// File: payload.config.ts
import { buildConfig } from "payload";
import { processMediaTask } from "@/payload/jobs/tasks/processMedia";

export default buildConfig({
  jobs: {
    tasks: [processMediaTask],
    autoRun: [
      {
        cron: "*/5 * * * * *",
        limit: 2,
      },
    ],
    access: {
      run: () => true,
      queue: () => true,
    },
  },
});

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.

ts
// File: lib/backblaze.ts
function encodeB2Path(filePath: string) {
  return filePath.split("/").map(encodeURIComponent).join("/");
}

export async function downloadFileFromBackblaze(identifier: string) {
  const auth = await authorizeAccount();

  if (!bucketName) {
    throw new Error("Missing B2_BUCKET_NAME.");
  }

  const response = await withRetry(() =>
    b2Client.get<Buffer>(
      `${auth.downloadUrl}/file/${encodeURIComponent(bucketName)}/${encodeB2Path(identifier)}`,
      {
        headers: {
          Authorization: auth.authorizationToken,
        },
        responseType: "arraybuffer",
      },
    ),
  );

  return {
    data: Buffer.from(response.data),
    contentType: response.headers["content-type"],
    status: response.status,
  };
}

export async function uploadFileToBackblaze(
  buffer: Buffer,
  filePath: string,
  mimeType: string,
) {
  if (!bucketId) {
    throw new Error("Missing B2_BUCKET_ID.");
  }

  const auth = await authorizeAccount();

  const uploadUrlResponse = await withRetry(() =>
    b2Client.post<{
      uploadUrl: string;
      authorizationToken: string;
    }>(
      `${auth.apiUrl}/b2api/v3/b2_get_upload_url`,
      { bucketId },
      {
        headers: {
          Authorization: auth.authorizationToken,
        },
      },
    ),
  );

  const sha1 = crypto.createHash("sha1").update(buffer).digest("hex");

  const uploadResponse = await withRetry(() =>
    b2Client.post<{ fileId: string; fileName: string }>(
      uploadUrlResponse.data.uploadUrl,
      buffer,
      {
        headers: {
          Authorization: uploadUrlResponse.data.authorizationToken,
          "X-Bz-File-Name": encodeB2Path(filePath),
          "X-Bz-Content-Sha1": sha1,
          "Content-Type": mimeType,
          "Content-Length": buffer.length.toString(),
        },
      },
    ),
  );

  return {
    fileId: uploadResponse.data.fileId,
    filePath: uploadResponse.data.fileName,
  };
}

This uses the same native B2 upload mechanism as the browser, but the SHA1 is calculated with Node's crypto module instead of crypto-js.

Deliver Through A CDN, Fall Back To The App

Upload architecture and delivery architecture are separate.

For public feed rendering, I prefer using a CDN pull zone in front of the B2 bucket:

ts
// File: utilities/cdn.ts
export function buildCanonicalCdnUrl(objectKey: string): string | null {
  const cdnBase = process.env.CDN_BASE_URL?.trim();

  if (!cdnBase) {
    return null;
  }

  const base = cdnBase.endsWith("/") ? cdnBase : `${cdnBase}/`;
  const encodedObjectKey = objectKey
    .split("/")
    .filter(Boolean)
    .map(encodeURIComponent)
    .join("/");

  return new URL(encodedObjectKey, base).toString();
}

When CDN_BASE_URL is present, your feed can render:

text
https://cdn.example.com/sara-nej/2026-07-10/ceremony-7a91c02f_display.webp

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.

ts
// File: lib/backblaze.ts
export async function buildAuthorizedDownloadUrl(
  objectKey: string,
  contentDisposition: string,
  ttlSeconds = 300,
) {
  if (!bucketName || !bucketId) {
    throw new Error("Missing B2 bucket configuration.");
  }

  const auth = await authorizeAccount();

  const tokenResponse = await withRetry(() =>
    b2Client.post<{ authorizationToken: string }>(
      `${auth.apiUrl}/b2api/v3/b2_get_download_authorization`,
      {
        bucketId,
        fileNamePrefix: objectKey,
        validDurationInSeconds: ttlSeconds,
        b2ContentDisposition: contentDisposition,
      },
      {
        headers: {
          Authorization: auth.authorizationToken,
        },
      },
    ),
  );

  const url = new URL(
    `/file/${encodeURIComponent(bucketName)}/${encodeB2Path(objectKey)}`,
    auth.downloadUrl,
  );

  url.searchParams.set("Authorization", tokenResponse.data.authorizationToken);
  url.searchParams.set("b2ContentDisposition", contentDisposition);

  return url.toString();
}

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