249 lines
7.3 KiB
Markdown
249 lines
7.3 KiB
Markdown
---
|
|
tags: [payloadcms, tech-patterns]
|
|
topic: payloadcms
|
|
sources: [upload__overview.md, upload__storage-adapters.md]
|
|
created: 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 |
|
|
|
|
### 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'
|
|
}
|
|
},
|
|
],
|
|
```
|
|
|
|
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`,
|
|
}
|
|
```
|
|
|
|
## 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
|
|
|
|
## Related
|
|
|
|
- [[wiki/payloadcms/configuration|Configuration]]
|
|
- [[wiki/payloadcms/fields-complex|Fields Complex]]
|
|
- [[wiki/payloadcms/admin-panel-overview|Admin Panel Overview]]
|