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.Transport is automatic
The plugin picks the transport from the zone:storage.s3istrue→ presigned S3. The browserPUTs straight to Bunny’s S3 endpoint. No Edge Script is involved, andclientUploads.edgeisn’t applicable.storage.s3isn’ttrue→ 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. 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 storedprefix includes the per-upload UUID folder, for example uploads/tenants/acme/3f2a…. Match it with like, not equals, in access rules and queries:
like rule matches both.
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/upload-instructions. All four are required. The plugin ignoresdocPrefixand uses its ownprefixlogic. PUTthe file torequest.urlwithrequest.headers. Send the exact byte count you declared.- Create the document with the returned
fileobject as thefilefield, 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.s3presigns directly — noedgeoptions, 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