12 KiB
| title | aliases | tags | topic | sources | created | updated | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| PayloadCMS — Upload & Media |
|
|
payloadcms |
|
2026-05-15 | 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 |
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) — 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'
}
},
],
Image size admin list view options
Hide specific sizes from the list view UI while keeping them available in the API:
{
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
{
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`,
}
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 causes400 Bad Request. Always use Payload's<Upload>component from@payloadcms/ui.
Minimal example
Server Component (/components/CustomUpload.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):
'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:
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):
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
Key Takeaways
- Enable uploads on any collection via
upload: trueorupload: { ... }— Payload auto-addsfilename,mimeType,filesizefields - Upload only works via REST and Local API — not GraphQL
sharpmust be in Payload config for image resizing (create-payload-appadds it by default)withoutEnlargementdefaults toundefined→ images smaller than a defined size returnnullfor 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: truewhen using cloud storage adapters (all official adapters do this automatically) - Restricted file types (
.exe,.php,.js, HTML, scripts) are blocked by default unlessmimeTypesis set orallowRestrictedFileTypes: true pasteURLis enabled by default; useallowListfor server-side fetching to bypass CORS on external URLs- Vercel limits server uploads to 4.5 MB — use
clientUploads: trueon the adapter to bypass