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 Payload’s shared POST /api/upload-instructions endpoint with { collectionSlug, filename, filesize, mimeType }. Payload hands the request to the plugin.
2

Authorize and validate

Payload first checks that the user is signed in and has create or update access to the collection. Then the plugin runs your access function, if you set one, and 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. The plugin then adds a random UUID folder, so the final key is <prefix>/<uuid>/<filename>. Two uploads with the same name never collide.Every upload collection has a hidden prefix field. It stores the full folder of each document, UUID included. 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 file object. Its uploadReference holds the stored prefix and 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 file object 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 Payload’s upload instructions, the same mechanism the official storage adapters use.

Transport is automatic

The plugin picks the transport from the zone:
  • storage.s3 is true → 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 true → 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.
Payload always checks first that the user is signed in and has create or update access to the collection. Your access function runs only after that check passes. It can deny a request that Payload would allow, but it cannot allow one that Payload denies. The endpoint always enforces the collection’s upload.mimeTypes, Payload’s upload.limits.fileSize and the restricted file types, whatever your access function returns.

Querying by prefix

The stored prefix includes the per-upload UUID folder, for example uploads/tenants/acme/3f2a…. Match it with like, not equals, in access rules and queries:
Files uploaded through the Payload server, and files from earlier plugin versions, have no UUID folder. A like rule matches both.

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/upload-instructions. All four are required. The plugin ignores docPrefix and uses its own prefix logic.
  2. PUT the file to request.url with request.headers. Send the exact byte count you declared.
  3. Create the document with the returned file object as the file field, unchanged. Payload rejects a client upload without a valid receipt.
upload.ts
instructions.file holds filename, mimeType, size and uploadReference. Use the returned filename, because Payload may rename the file to keep it unique. Errors use Payload’s standard format, { errors: [{ message }] }. Payload runs its own checks before the plugin, so check response.ok rather than exact status codes. For Stream with TUS, the instructions have type: 'dispatch' and the admin UI uploads the video over TUS. Custom clients use the tus-auth endpoint instead. See the API reference.

Client upload requirements

The options 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 options, 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 examples.