Skip to main content
Client uploads send the file bytes straight from the browser to Bunny. The Payload server only mints a short-lived, signed upload URL — it never receives the file itself. This removes serverless body-size limits (Vercel caps request bodies at ~4.5 MB) and keeps large uploads off your app server entirely. They are configured under storage.clientUploads:
payload.config.ts
Client uploads cover Bunny Storage files. For large Stream videos, use TUS resumable uploads instead — TUS is the browser-direct path for Stream. With TUS enabled on the same collection, videos from bulk uploads and plain form saves go to Stream over TUS, and other files use client uploads.
Without client uploads, files travel through Payload as multipart form data. Payload limits these to 20 MB per file and 50 MB per request by default. Change them with upload.limits.fileSize and upload.requestSizeLimit in your Payload config, or turn on client uploads.

How it works

1

Request

When an editor picks a file, the admin UI calls POST /api/storage-bunny/storage/upload with { collectionSlug, filename, filesize, mimeType }.
2

Authorize and validate

The server runs the access check. By default the user needs create or update access to the collection. Then it validates the file. filesize and mimeType are required. The file must match the collection’s upload.mimeTypes, Payload’s upload.limits.fileSize and, for edge transport, edge.maxSize. Restricted file types are rejected.
3

Resolve the path

The prefix callback (or the collection’s static prefix) decides where the file lands, server-side. If the collection also has a static prefix, a callback result outside it is placed under it. For example, uploads plus tenants/acme gives uploads/tenants/acme. When you use a prefix callback, the plugin injects a hidden per-document prefix field. Later reads, deletes and URL generation all resolve to the same path.
4

Mint the URL

The server returns a presigned (S3) or signed (edge) upload URL scoped to that path. The URL only accepts the declared size and MIME type. It never overwrites an existing file. If a file already exists at the path, S3 mode answers 409 before it mints a URL and rejects a repeated PUT with 412. The Edge Script answers 409 to the upload.The response also contains a clientUploadContext with a signed receipt.
5

Upload

The browser PUTs the file straight to Bunny. Your app server is never in the data path.
6

Save the document

The admin UI sends the clientUploadContext back with the document. Payload checks the receipt, then the plugin stores the prefix, file size and MIME type from the receipt.
This is built on @payloadcms/plugin-cloud-storage’s client-uploads mechanism.

Transport is automatic

The plugin picks the transport from the zone:
  • storage.s3 is set → presigned S3. The browser PUTs straight to Bunny’s S3 endpoint. No Edge Script is involved, and clientUploads.edge isn’t applicable.
  • storage.s3 isn’t set → edge. The upload is proxied through a Bunny Edge Script, so clientUploads.edge is required.
A collection that points at its own storage zone follows that zone’s transport: its own s3 presigns against its own endpoint, its own edge mints against its own script.

Client upload options

Pass storage.clientUploads: true for defaults, false to disable, or an object:
When the zone sets storage.s3, the clientUploads object accepts only access and prefix — the edge block isn’t available for S3 zones. This is enforced by the type: a storage zone is either HTTP API (needs edge) or S3 (presigns directly).

S3 transport

When the zone has storage.s3, the browser sends a presigned PUT straight to Bunny’s S3 endpoint. This requires a storage zone created with S3 compatibility enabled.
payload.config.ts
No Edge Script to deploy: the presigned URL is signed with your existing storage credentials.

Edge transport

Without storage.s3, the file is proxied through a Bunny Edge Script that you deploy once with bunny:deploy-edge-script. clientUploads.edge is required.
payload.config.ts
The server mints a signed HMAC URL for each upload. It appends an X-Upload-Expires timestamp, an X-Upload-Zone name, the file size (X-Upload-Size), the MIME type (X-Upload-Type) and an HMAC-SHA256 signature over the request. The script checks that Content-Length matches the signed size, stores the file with the signed MIME type, and refuses to overwrite an existing file. The Edge Script rejects anything whose signature doesn’t verify against its SHARED_SECRET, so edge.secret must match the secret the script was deployed with. Each zone’s storage credentials live in their own ZONE_<NAME> script secret, so zones are added and removed independently — re-deploying to add a zone never touches the other zones’ secrets or the shared SHARED_SECRET.
Redeploy the Edge Script with bunny:deploy-edge-script after you upgrade the plugin. An older script does not check the signed size and type.

Access control

access gates who may request an upload URL. It receives { collectionSlug, req } and returns a boolean (or a promise). It runs before the prefix callback, so an unauthorized caller never reaches path resolution.
By default, the user must be signed in and have create or update access to the collection. A custom access function replaces this check. The endpoint always enforces the collection’s upload.mimeTypes, Payload’s upload.limits.fileSize and the restricted file types, whatever your access function returns.

Restricted file types

Unless the collection sets upload.allowRestrictedFileTypes: true, the endpoint rejects these files by extension and by MIME type:
  • HTML and scripts: .html, .htm, .shtml, .xhtml, .js and .mjs (400 or 415).
  • SVG and XML files (400). These must go through Payload, which validates their content on the server.

SVG and XML files skip client uploads

The admin UI sends SVG and XML files through the Payload server, not through client uploads. So the prefix callback never runs for them, and they land under the collection’s static prefix. If you need a per-document folder for these files too, set prefix in a collection beforeChange hook. See the multi-tenant guide for a recipe.

Custom upload clients

The admin UI handles all of this for you. If you build your own upload flow, follow these steps:
  1. Send collectionSlug, filename, filesize and mimeType to POST /api/storage-bunny/storage/upload. All four are required.
  2. PUT the file to the returned url with the returned headers. Send the exact byte count you declared.
  3. Create the document with the returned filename, and send the returned clientUploadContext unchanged. Payload rejects a client upload without a valid receipt.
See the API reference for the full request and response shapes.

Client upload requirements

The config validator throws a startup error if any of these is violated:
  • Bunny Storage must be enabled for the collection.
  • A zone with storage.s3 presigns directly — no edge config, nothing to deploy.
  • A zone without storage.s3 requires storage.clientUploads.edge.scriptUrl and edge.secret.
  • All non-s3 zones that share the same edge.scriptUrl must use the same edge.secret — one deployed script holds one shared secret.

Per-collection client uploads

Set storage.clientUploads per collection to enable, disable, or reconfigure browser-direct uploads. A collection inherits the global storage.clientUploads unless it sets its own:
payload.config.ts
See Collection overrides for the full list of options you can tune per collection, and Examples for complete configs.