7.3 KiB
| tags | topic | sources | created | ||||
|---|---|---|---|---|---|---|---|
|
payloadcms |
|
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:
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):
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) — returnsnullwhen upload is smaller than defined sizefalse— always enlargetrue— return original image when smaller
Custom filename via beforeOperation hook
beforeOperation: [
({ req, operation }) => {
if ((operation === 'create' || operation === 'update') && req.file) {
req.file.name = 'custom-name.jpg'
}
},
],
Custom per-size filename:
{
name: 'thumbnail',
width: 400, height: 300,
generateImageName: ({ height, sizeName, extension, width }) =>
`custom-${sizeName}-${height}-${width}.${extension}`,
}
Code Examples
Upload via REST (browser)
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)
await payload.create({
collection: 'media',
data: { alt: 'Seeded image' },
filePath: path.resolve(__dirname, 'seed/image.jpg'),
})
Restrict paste-URL to trusted domains
upload: {
pasteURL: {
allowList: [
{ hostname: 'assets.example.com', protocol: 'https', pathname: '/images/*' },
],
},
}
Custom admin thumbnail via function
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):
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):
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)
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'
plugins: [
cloudStoragePlugin({
collections: {
'my-collection': { adapter: myCustomAdapter },
},
}),
]
GeneratedAdapter interface requires: handleDelete, handleUpload, staticHandler, name.
Gotchas
sharpmust be configured in Payload config for image resizing to work (auto-configured bycreate-payload-app)withoutEnlargementdefaults toundefined— images smaller than a defined size returnnullfor 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
imageSizesorresizeOptionsis defined - Vercel server uploads are limited to 4.5 MB — set
clientUploads: trueon 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 unlessmimeTypesis set orallowRestrictedFileTypes: 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