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.@payloadcms/plugin-cloud-storage’s client-uploads mechanism.
Transport is automatic
The plugin picks the transport from the zone:storage.s3is set → presigned S3. The browserPUTs straight to Bunny’s S3 endpoint. No Edge Script is involved, andclientUploads.edgeisn’t applicable.storage.s3isn’t set → edge. The upload is proxied through a Bunny Edge Script, soclientUploads.edgeis required.
s3 presigns against its own endpoint, its own edge mints against its own script.
Client upload options
Passstorage.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 hasstorage.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
Edge transport
Withoutstorage.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
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.
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.
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 setsupload.allowRestrictedFileTypes: true, the endpoint rejects these files by extension and by MIME type:
- HTML and scripts:
.html,.htm,.shtml,.xhtml,.jsand.mjs(400or415). - 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 theprefix 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:- Send
collectionSlug,filename,filesizeandmimeTypetoPOST /api/storage-bunny/storage/upload. All four are required. PUTthe file to the returnedurlwith the returnedheaders. Send the exact byte count you declared.- Create the document with the returned
filename, and send the returnedclientUploadContextunchanged. Payload rejects a client upload without a valid receipt.
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.s3presigns directly — noedgeconfig, nothing to deploy. - A zone without
storage.s3requiresstorage.clientUploads.edge.scriptUrlandedge.secret. - All non-s3 zones that share the same
edge.scriptUrlmust use the sameedge.secret— one deployed script holds one shared secret.
Per-collection client uploads
Setstorage.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