> ## Documentation Index
> Fetch the complete documentation index at: https://payload-storage-bunny.seshuk.im/llms.txt
> Use this file to discover all available pages before exploring further.

# Client uploads

> Send file bytes straight from the browser to Bunny using presigned S3 or an Edge Script, bypassing the Payload server.

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`:

```ts payload.config.ts theme={null}
bunnyStorage({
  collections: { media: true },
  storage: {
    apiKey: process.env.BUNNY_STORAGE_API_KEY,
    hostname: 'example.b-cdn.net',
    zoneName: 'my-zone',
    s3: true,
    clientUploads: true, // enable with defaults
  },
})
```

<Info>
  Client uploads cover **Bunny Storage** files. For large **Stream videos**, use [TUS resumable uploads](/v4/configuration/stream/tus) 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.
</Info>

<Info>
  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.
</Info>

## How it works

<Steps>
  <Step title="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.
  </Step>

  <Step title="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`](#access-control) 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](#restricted-file-types) are rejected.
  </Step>

  <Step title="Resolve the path">
    The [`prefix`](#client-upload-options) 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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Upload">
    The browser `PUT`s the file straight to Bunny. Your app server is never in the data path.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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`](/v4/configuration/storage/overview#s3-mode) is `true` → presigned S3.** The browser `PUT`s 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](/v4/configuration/collection-overrides) 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:

| Option | Type | Required | Default | Description |
| - | - | - | - | - |
| `access` | `(args) => boolean \| Promise` | Optional | create or update access | Decides who may request an upload URL. Receives `{ collectionSlug, req }`. See [Access control](#access-control). |
| `edge.scriptUrl` | `string` | edge zones | – | Deployed Edge Script URL, e.g. `https://my-uploader.b-cdn.net`. Required when the zone has no `s3`; not applicable when `s3` is set. |
| `edge.secret` | `string` | edge zones | – | Shared HMAC secret. Must match the script's `SHARED_SECRET`. Required when the zone has no `s3`. |
| `edge.maxSize` | `number` | Optional | `1073741824` (1 GiB) | Max accepted file size in bytes. Also enforced by the Edge Script. |
| `prefix` | `(args) => string \| Promise` | Optional | the collection's static prefix | Resolve the storage path prefix at mint time, server-side. Use it for date/user folders or a tenant segment. See [Multi-tenant](/v4/guides/multi-tenant#client-uploads-prefix-at-mint-time). |

<Info>
  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).
</Info>

## S3 transport

When the zone has [`storage.s3`](/v4/configuration/storage/overview#s3-mode), the browser sends a presigned `PUT` straight to Bunny's S3 endpoint. This requires a storage zone created with S3 compatibility enabled.

```ts payload.config.ts theme={null}
bunnyStorage({
  collections: { media: true },
  storage: {
    apiKey: process.env.BUNNY_STORAGE_API_KEY,
    hostname: 'example.b-cdn.net',
    zoneName: 'my-zone',
    s3: true,
    clientUploads: true, // presigned S3, because storage.s3 is set
  },
})
```

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](https://docs.bunny.net/docs/edge-scripting?ref=fndfoymy0j) that you deploy once with [`bunny:deploy-edge-script`](/v4/cli/deploy-edge-script). `clientUploads.edge` is required.

```ts payload.config.ts theme={null}
bunnyStorage({
  collections: { media: true },
  storage: {
    apiKey: process.env.BUNNY_STORAGE_API_KEY,
    hostname: 'example.b-cdn.net',
    zoneName: 'my-zone',
    clientUploads: {
      edge: {
        scriptUrl: 'https://my-uploader.b-cdn.net',
        secret: process.env.BUNNY_EDGE_UPLOAD_SECRET,
      },
    },
  },
})
```

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`.

<Warning>
  Redeploy the Edge Script with [`bunny:deploy-edge-script`](/v4/cli/deploy-edge-script) after you upgrade the plugin. An older script does not check the signed size and type.
</Warning>

## 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.

```ts theme={null}
storage: {
  // ...
  clientUploads: {
    access: ({ req }) => req.user?.role === 'editor' || req.user?.role === 'admin',
  },
}
```

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:

```ts theme={null}
import type { Access } from 'payload'

const canRead: Access = ({ req }) => ({
  prefix: { like: `uploads/tenants/${req.user?.tenant}/` },
})
```

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](/v4/guides/multi-tenant#server-side-uploads-prefix-by-tenant) 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`](#client-upload-options) 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.

```ts upload.ts theme={null}
const response = await fetch('/api/upload-instructions', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    collectionSlug: 'media',
    filename: file.name,
    filesize: file.size,
    mimeType: file.type,
  }),
})

if (!response.ok) {
  const { errors } = await response.json()
  throw new Error(errors[0]?.message)
}

const instructions = await response.json()

if (instructions.type !== 'http') {
  throw new Error(`Unsupported upload instruction: ${instructions.type}`)
}

const { headers, method, url } = instructions.request
await fetch(url, { method, headers, body: file })

const formData = new FormData()
formData.append('_payload', JSON.stringify({ alt: 'My image' }))
formData.append('file', JSON.stringify(instructions.file))

await fetch('/api/media', {
  method: 'POST',
  credentials: 'include',
  body: formData,
})
```

`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](/v4/configuration/stream/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](/v4/api-reference/overview).

## 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:

```ts payload.config.ts theme={null}
collections: {
  media: true,          // inherits the global storage.clientUploads
  documents: {
    storage: {
      clientUploads: false, // no browser-direct uploads for this collection
    },
  },
  archive: {
    // full per-collection zone with its own edge options
    storage: {
      apiKey: process.env.BUNNY_ARCHIVE_STORAGE_API_KEY,
      hostname: 'archive.b-cdn.net',
      zoneName: 'archive-zone',
      clientUploads: {
        edge: { scriptUrl: 'https://my-uploader.b-cdn.net', secret: process.env.BUNNY_EDGE_UPLOAD_SECRET },
      },
    },
  },
}
```

See [Collection overrides](/v4/configuration/collection-overrides) for the full list of options you can tune per collection, and [Examples](/v4/guides/examples#browser-direct-client-uploads) for complete examples.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.