---
title: "PayloadCMS — Upload & Media"
aliases: [payload-upload, payload-media, payload-file-upload]
tags: [payloadcms, tech-patterns, upload, media]
topic: payloadcms
sources: [upload__overview.md, upload__storage-adapters.md]
created: 2026-05-15
updated: 2026-05-15
---

# PayloadCMS — Upload & Media

## Overview

Enabling `upload` on a collection transforms it into a full file management system. Payload auto-injects `filename`, `mimeType`, `filesize` (and optionally `sizes`) fields and modifies the Admin Panel list/edit views to support file operations.

Common use cases:
- Media library for site images
- Gated content (PDFs, ebooks behind auth)
- Publicly downloadable assets (ZIPs, MP4s)

Upload only works via REST and Local APIs — not GraphQL.

## Setup / Config

Minimal collection setup:

```ts
import type { CollectionConfig } from 'payload'

export const Media: CollectionConfig = {
  slug: 'media',
  upload: {
    staticDir: 'media',        // relative to config file
    mimeTypes: ['image/*'],
    adminThumbnail: 'thumbnail',
    imageSizes: [
      { name: 'thumbnail', width: 400, height: 300, position: 'centre' },
      { name: 'card', width: 768, height: 1024, position: 'centre' },
      { name: 'tablet', width: 1024, height: undefined, position: 'centre' },
    ],
  },
  fields: [{ name: 'alt', type: 'text' }],
}
```

Global file size limit (Busboy options):

```ts
export default buildConfig({
  upload: {
    limits: { fileSize: 5_000_000 }, // 5 MB in bytes
  },
})
```

## Key Options

### Collection-level `upload` config

| Option | Notes |
|---|---|
| `staticDir` | Local storage folder; defaults to collection slug |
| `imageSizes` | Array of resize targets (uses `sharp`) |
| `adminThumbnail` | String (size name) or `({ doc }) => url` function |
| `mimeTypes` | Array — restricts file picker: `['image/*', 'application/pdf']` |
| `disableLocalStorage` | Set `true` when using a cloud storage adapter |
| `crop` / `focalPoint` | Both `true` by default; set `false` to disable UI tools |
| `resizeOptions` | Single sharp resize (alternative to `imageSizes`) |
| `formatOptions` | Sharp output format override |
| `pasteURL` | `false` to disable URL paste; object with `allowList` for server-side CORS fetch |
| `bulkUpload` | `true` by default |
| `filesRequiredOnCreate` | `true` by default |
| `withMetadata` | Append EXIF metadata to output image |
| `modifyResponseHeaders` | Manipulate HTTP response headers for served files |
| `allowRestrictedFileTypes` | `true` to bypass blocked executable/script extension list |
| `hideFileInputOnCreate` | `true` to hide file input during document creation (for programmatic file generation) |
| `hideRemoveFile` | `true` to hide the remove-file button in the edit view |
| `constructorOptions` | Object passed to Sharp constructor (applied to every upload file) |
| `cacheTags` | `false` to disable cache tags set in UI for the admin thumbnail (for CDNs that block cache queries) |
| `externalFileHeaderFilter` | Filter/modify headers when fetching external files; must strip `payload-*` cookies manually if provided |
| `filenameCompoundIndex` | Field slugs for a compound index instead of the default filename index |
| `skipSafeFetch` | `allowList` array or `true` — skip safe-fetch SSRF check for specific/all external URLs |

### Image sizes — `withoutEnlargement`

- `undefined` (default) — returns `null` when upload is smaller than defined size
- `false` — always enlarge
- `true` — return original image when smaller

### Custom filename via `beforeOperation` hook

```ts
beforeOperation: [
  ({ req, operation }) => {
    if ((operation === 'create' || operation === 'update') && req.file) {
      req.file.name = 'custom-name.jpg'
    }
  },
],
```

### Image size admin list view options

Hide specific sizes from the list view UI while keeping them available in the API:

```ts
{
  name: 'thumbnail',
  width: 400, height: 300,
  admin: {
    disableGroupBy: true,    // hide from groupBy options
    disableListColumn: true, // hide from column picker
    disableListFilter: true, // hide from filter options
  },
}
```

### Custom per-size filename

```ts
{
  name: 'thumbnail',
  width: 400, height: 300,
  generateImageName: ({ height, sizeName, extension, width }) =>
    `custom-${sizeName}-${height}-${width}.${extension}`,
}
```

## Code Examples

### Upload via REST (browser)

```ts
const formData = new FormData()
formData.append('file', fileInput.files[0])
formData.append('_payload', JSON.stringify({ alt: 'My image' }))
// Do NOT set Content-Type manually — browser sets multipart boundary
fetch('/api/media', { method: 'POST', body: formData })
```

### Upload from local path (seed scripts)

```ts
await payload.create({
  collection: 'media',
  data: { alt: 'Seeded image' },
  filePath: path.resolve(__dirname, 'seed/image.jpg'),
})
```

### Restrict paste-URL to trusted domains

```ts
upload: {
  pasteURL: {
    allowList: [
      { hostname: 'assets.example.com', protocol: 'https', pathname: '/images/*' },
    ],
  },
}
```

### Custom admin thumbnail via function

```ts
upload: {
  adminThumbnail: ({ doc }) =>
    `https://cdn.example.com/${doc.filename}?w=200`,
}
```

## Custom Upload UI

You can replace the default upload interface with custom React components via `admin.components.edit.Upload`.

> **Critical:** Never use a raw `<input type="file" />` — it won't connect to Payload's form state and causes `400 Bad Request`. Always use Payload's `<Upload>` component from `@payloadcms/ui`.

### Minimal example

**Server Component** (`/components/CustomUpload.tsx`):
```tsx
import { CustomUploadClient } from './CustomUpload.client'
export const CustomUploadServer = (props) => (
  <div>
    <h2>Custom Upload Interface</h2>
    <CustomUploadClient {...props} />
  </div>
)
```

**Client Component** (`/components/CustomUpload.client.tsx`):
```tsx
'use client'
import { Upload, useDocumentInfo } from '@payloadcms/ui'
export const CustomUploadClient = () => {
  const { collectionSlug, docConfig, initialState } = useDocumentInfo()
  return (
    <Upload
      collectionSlug={collectionSlug}
      initialState={initialState}
      uploadConfig={'upload' in docConfig ? docConfig.upload : undefined}
    />
  )
}
```

**Collection config:**
```ts
admin: {
  components: {
    edit: {
      Upload: '/components/CustomUpload#CustomUploadServer',
    },
  },
},
```

### Available hooks & components from `@payloadcms/ui`

| Hook / Component | Description |
|---|---|
| `useDocumentInfo()` | Collection slug, doc config, initial state |
| `useField()` | Access/manipulate form field state |
| `useBulkUpload()` | Bulk upload context |
| `<Upload>` | Main upload component (drag-drop, preview, `customActions` prop) |
| `<Drawer>` / `<DrawerToggler>` | Modal drawer + trigger button |
| `<TextField>` etc. | Form field components |

### Upload Collection vs Upload Field customization

| Approach | Config location | Use case |
|---|---|---|
| Upload Collection | `admin.components.edit.Upload` | Customize the media collection edit view |
| Upload Field | `admin.components.Field` on an upload field | Customize the field that references uploads in other collections |

## Storage Adapters

All adapters auto-set `disableLocalStorage: true` per collection.

| Service | Package |
|---|---|
| Vercel Blob | `@payloadcms/storage-vercel-blob` |
| AWS S3 | `@payloadcms/storage-s3` |
| Azure Blob | `@payloadcms/storage-azure` |
| GCS | `@payloadcms/storage-gcs` |
| Uploadthing | `@payloadcms/storage-uploadthing` |
| R2 (Workers) | `@payloadcms/storage-r2` |

S3 example (also works for Cloudflare R2 via S3 API):

```ts
import { s3Storage } from '@payloadcms/storage-s3'

plugins: [
  s3Storage({
    collections: { media: true },
    bucket: process.env.S3_BUCKET,
    config: {
      credentials: {
        accessKeyId: process.env.S3_ACCESS_KEY_ID,
        secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
      },
      region: process.env.S3_REGION,
    },
  }),
]
```

Cloudflare R2 via S3 API (Node.js / Vercel):

```ts
s3Storage({
  enabled: Boolean(process.env.R2_BUCKET),
  collections: {
    media: {
      disablePayloadAccessControl: true,
      generateFileURL: ({ filename, prefix }) => {
        const key = prefix ? `${prefix}/${filename}` : filename
        return `${process.env.R2_PUBLIC_URL}/${key}`
      },
    },
  },
  bucket: process.env.R2_BUCKET,
  config: {
    credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY },
    region: 'auto',           // required by R2
    endpoint: process.env.R2_ENDPOINT,
    forcePathStyle: true,     // required for R2
  },
})
```

### Prefix composition

By default a document-level prefix overrides the collection prefix. Use `useCompositePrefixes: true` to combine them:

```
Collection prefix: uploads   +   Document prefix: user-123
Default  → user-123/file.jpg
Composite → uploads/user-123/file.jpg
```

### Payload Access Control for cloud files

By default file URLs stay at `/collectionSlug/staticURL/filename` so access control still applies. Set `disablePayloadAccessControl: true` only when your collection uses `read: () => true` and you want direct CDN URLs.

### Custom storage adapter (base plugin)

```ts
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'

plugins: [
  cloudStoragePlugin({
    collections: {
      'my-collection': { adapter: myCustomAdapter },
    },
  }),
]
```

`GeneratedAdapter` interface requires: `handleDelete`, `handleUpload`, `staticHandler`, `name`.

## Gotchas

- `sharp` must be configured in Payload config for image resizing to work (auto-configured by `create-payload-app`)
- `withoutEnlargement` defaults to `undefined` — images smaller than a defined size return `null` for that size, not the original
- Image cropping happens **before** resizing — resized sizes are derived from the cropped image
- Focal point selector only shows up when `imageSizes` or `resizeOptions` is defined
- Vercel server uploads are limited to 4.5 MB — set `clientUploads: true` on the adapter to bypass
- R2: the S3 API endpoint is for uploads only; serve files via `R2_PUBLIC_URL` (r2.dev subdomain or custom domain)
- Restricted file types (`.exe`, `.php`, `.js`, HTML, scripts) are blocked by default unless `mimeTypes` is set or `allowRestrictedFileTypes: true`
- Custom upload components must wrap Payload's `<Upload>` from `@payloadcms/ui` — a raw `<input type="file">` won't connect to the form state and causes 400 errors

## Key Takeaways

- Enable uploads on any collection via `upload: true` or `upload: { ... }` — Payload auto-adds `filename`, `mimeType`, `filesize` fields
- Upload only works via REST and Local API — **not** GraphQL
- `sharp` must be in Payload config for image resizing (`create-payload-app` adds it by default)
- `withoutEnlargement` defaults to `undefined` → images smaller than a defined size return `null` for that size
- Image cropping happens **before** resizing — all sizes are derived from the cropped image
- Custom Upload components **must** use `<Upload>` from `@payloadcms/ui` — raw `<input type="file">` causes 400 errors
- Set `disableLocalStorage: true` when using cloud storage adapters (all official adapters do this automatically)
- Restricted file types (`.exe`, `.php`, `.js`, HTML, scripts) are blocked by default unless `mimeTypes` is set or `allowRestrictedFileTypes: true`
- `pasteURL` is enabled by default; use `allowList` for server-side fetching to bypass CORS on external URLs
- Vercel limits server uploads to 4.5 MB — use `clientUploads: true` on the adapter to bypass

## Related

- [[wiki/payloadcms/configuration|Payload Config Overview]]
- [[wiki/payloadcms/fields-upload|Upload Field]]
- [[wiki/payloadcms/admin-panel-overview|Admin Panel Overview]]
- [[wiki/payloadcms/custom-components-edit-view|Custom Components — Edit View]]
- [[wiki/payloadcms/access-control|Access Control]]
- [[wiki/payloadcms/production-deployment|Production Deployment]]
