obsidian/wiki/payloadcms/upload.md
2026-05-15 16:54:04 +01:00

12 KiB

title aliases tags topic sources created updated
PayloadCMS — Upload & Media
payload-upload
payload-media
payload-file-upload
payloadcms
tech-patterns
upload
media
payloadcms
upload__overview.md
upload__storage-adapters.md
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) — returns null when upload is smaller than defined size
  • false — always enlarge
  • true — 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 causes 400 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

  • 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