Quick answer: In Payload CMS, an upload collection is an ordinary collection with an upload config block — upload: true or an options object listing staticDir, imageSizes, mimeTypes, adminThumbnail and friends. Other collections point at it with a field of type: 'upload' and a relationTo. Files can be created from code with the Local API (payload.create plus filePath or a file object) or over REST as multipart form data to /api/{collection-slug}, and where they physically live is decided by a storage adapter rather than by the collection itself.
Part 9 of our Payload CMS series. Start with what Payload CMS is and install Payload and build your first collection, then read authentication and role-based access control before you expose file uploads to anyone.
Table of Contents
1. An upload collection is still a collection
The mental model that makes uploads stop being confusing: media in Payload is a normal collection that happens to own files. It has a slug, fields, access rules, hooks and an admin listing like any other collection, plus one extra config key called upload that switches on file handling.
That one decision shapes everything else:
- A
mediacollection is the conventional name, and its slug is what other collections reference withrelationTo: 'media'. - The upload document is a row in the database. The file is a blob somewhere — local disk by default, object storage once you configure an adapter.
- Because it is a collection, the same access-control rules from the RBAC article apply. If you can write an access rule for posts, you already know how to write one for files.
// collections/Media.ts
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
create: ({ req: { user } }) => Boolean(user),
delete: ({ req: { user } }) => user?.role === 'admin',
},
upload: {
staticDir: 'media',
mimeTypes: ['image/*'],
},
fields: [
{ name: 'alt', type: 'text', required: true },
],
}
fields is where you put the metadata you actually care about: alt text, a caption, a credit line, a usageRights select. Every one of those is queryable later, which is the difference between a real media library and a folder of loose files.
If a collection only needs files and nothing else, upload: true is the shorthand — but you will almost always want at least an alt field, so the object form is the honest default.
2. The upload config, option by option
The collection-level upload object is where all file behaviour is declared. The options you will actually use:
| Option | What it does |
|---|---|
staticDir | Local directory for the files. Defaults to the collection slug. |
imageSizes | Generates resized image variants. Nothing is generated until you list sizes here. |
adminThumbnail | Which thumbnail the admin list uses: a size name, or a function returning a URL. |
mimeTypes | Restricts what the picker accepts, for example image/*. |
disableLocalStorage | Stops Payload writing to local disk. Storage adapters set this for their collections. |
crop | Admin crop tool. On by default; false turns it off. |
focalPoint | Focal-point selector. On by default, and only shown when imageSizes or resizeOptions exist. |
formatOptions | Sharp output format and options, for example WebP at a given quality. |
resizeOptions / constructorOptions / trimOptions | Sharp-level control over the resize pass. |
withMetadata | Boolean or function controlling the metadata written into the image. |
displayPreview | Whether related upload fields show an inline preview of the file. |
bulkUpload | Multi-file upload from the collection list. Defaults to true. |
filesRequiredOnCreate | Whether a file must accompany a new document. Defaults to true. |
pasteURL | Pasting a remote URL into the upload field. On by default; allowList enables server-side fetching from trusted patterns. |
handlers | Custom request handlers for file retrieval. |
hideFileInputOnCreate, hideRemoveFile | Hide the file input or the remove control in the admin, useful when files are generated in code. |
Two of those deserve a warning, because they are the ones people misread:
disableLocalStoragedoes not make files private. It only stops Payload writing to disk. Privacy comes from access control, which section 7 covers.pasteURLis an inbound upload feature. It lets an editor hand Payload a remote URL to fetch, and it says nothing about how files are served out again.
If you are porting configuration from an older project, do not assume every key you remember still exists. Names such as allowEdit and thumbnailSize are not part of the current documented upload options, and inventing them silently does nothing.
3. Upload fields: attaching a file to a document
Other collections reference uploads with a field, not a string path:
// collections/Posts.ts — field excerpt
{ name: 'featuredImage', type: 'upload', relationTo: 'media' },
{ name: 'gallery', type: 'upload', relationTo: 'media', hasMany: true, maxRows: 6 },
The upload field accepts a single collection slug, or an array of slugs when you want a polymorphic field that can hold an image or a PDF or a video. Useful options:
| Field option | What it does |
|---|---|
relationTo | One upload collection slug, or several for a polymorphic field. Required. |
hasMany | Allow multiple referenced files. Pair it with minRows / maxRows to bound the list. |
filterOptions | A query, or a function returning one, that limits which documents can be selected. |
maxDepth | Caps how deep the related upload document is populated on reads. |
displayPreview | Overrides the collection-level preview setting for this field. |
filterOptions is the underrated one. A “hero image” field that only offers landscape images, or an attachment field restricted to a specific mime type, is a query constraint — not something you police in code review.
Referencing media works like any other reused collection, so a featuredImage on Posts, an avatar on Authors and an image inside a page-builder block all point at one library instead of three copies of the same file.
4. Creating files from code
Two documented paths, and they are not interchangeable.
Local API, from a file on disk. Pass an absolute filePath and the metadata you want stored:
import path from 'path'
const media = await payload.create({
collection: 'media',
data: { alt: 'Dashboard screenshot' },
filePath: path.resolve('./tmp/dashboard.png'),
})
You can pass a file object instead of a filePath. If you have bytes in memory rather than a file, write them out and use filePath, or convert to a supported file representation — a raw Node Buffer is not a documented input.
REST, from a browser or another service. A multipart POST to the upload collection’s create endpoint, with the file under the key file and the rest of the document JSON-encoded into _payload:
const formData = new FormData()
formData.append('file', file)
formData.append('_payload', JSON.stringify({ alt: 'Dashboard screenshot' }))
await fetch('/api/media', { method: 'POST', body: formData })
Let the browser set the multipart Content-Type and boundary — setting it by hand is the classic way to break this. Server-side, hooks such as beforeOperation see the incoming upload on req.file, which is where you validate size or generate a derived field.
One asymmetry to remember: the Local API skips access control by default. When an upload is created on behalf of a signed-in user, pass overrideAccess: false along with the user, exactly as with any other collection.
5. imageSizes, sharp and adminThumbnail
imageSizes is where a media library earns its keep: one upload, several ready-to-serve variants instead of a 4 MB original in every card.
upload: {
imageSizes: [
{ name: 'thumbnail', width: 300, height: 300, position: 'centre' },
{ name: 'card', width: 768 },
{ name: 'hero', width: 1600 },
],
adminThumbnail: 'thumbnail',
}
A size needs a name; width alone keeps the original aspect ratio, while width plus height with a position produces a real crop. Per size you also get generateImageName for custom file naming, withoutEnlargement to stop small originals being upscaled, and admin flags (disableGroupBy, disableListColumn, disableListFilter) that tidy up the file listing.
Generated variants are recorded on the upload document’s sizes data, so your frontend can read media.sizes.card.url rather than reconstructing filenames. When you need the buffers mid-flight, hooks expose req.payloadUploadSizes.
Resizing is done by sharp, which is an optional dependency in the 3.x line: install it and pass it into the base config, or resizing and cropping simply will not happen.
import sharp from 'sharp'
export default buildConfig({
// ...
sharp,
})
adminThumbnail takes either a size name or a function that receives { doc } and returns a URL — the function form exists precisely for the case where files live in object storage and the admin thumbnail URL is not derivable from the size name.
6. Crop, focal point and what the editor actually sees
The admin offers a crop tool and a focal-point selector on image uploads. Worth knowing before an editor asks:
- Cropping happens before resizing, so every generated variant derives from the cropped image rather than from the untouched original. Cropping the top of a screenshot does not leave a stale variant behind.
- The focal-point selector only appears when
imageSizesorresizeOptionsare configured. A media collection with no sizes has nothing to focus, so the control is absent. - The focal point is stored on the document, and your frontend is expected to use it when fitting the image into a container — the CSS
object-positionvalue is the natural destination. Read the exact fields from your generated types rather than hard-coding names; that metadata has changed shape across major versions.
Both tools can be turned off (crop: false, focalPoint: false) when you want uploads to be exactly what was uploaded.
7. Private media and access control
File URLs are governed by the upload collection’s access.read rule. That single sentence has three consequences people trip over.
- A public media collection is a public bucket.
read: () => truemeans anyone with the URL gets the file. That is right for a blog’s images and wrong for invoices, contracts or member photos. - Storage adapters route file URLs through Payload by default so that access checks can run. Setting
disablePayloadAccessControl: trueon a cloud-storage collection sends the browser straight at the cloud host, which is faster and public — appropriate only when that is deliberate. - Cloud adapters can issue signed downloads (the S3 adapter has a
signedDownloadsoption) so a private file is fetched through a short-lived URL while still respecting Payload’s access rules.
For genuinely private files, the honest options are access rules plus signed downloads, or a custom route that authorizes the request itself. upload.handlers lets you return your own Response for a file request, and Payload’s custom endpoints are the other hook point — but a custom route does not inherit the collection’s file-serving checks for free. If you write one, you own the authorization.
8. Storage adapters: where the bytes actually go
Local disk is the default and the wrong answer for anything that will be redeployed — the production article treats that move as a deployment decision rather than a feature. In the 3.x line the official adapters are separate packages, configured as plugins and keyed by collection:
import { s3Storage } from '@payloadcms/storage-s3'
export default buildConfig({
plugins: [
s3Storage({
collections: { media: { prefix: 'uploads' } },
bucket: process.env.S3_BUCKET as string,
config: {
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_KEY as string,
secretAccessKey: process.env.S3_SECRET as string,
},
},
}),
],
})
| Service | Package | Notes |
|---|---|---|
| AWS S3 | @payloadcms/storage-s3 | s3Storage({ collections, bucket, config }); supports prefixes, clientUploads and signedDownloads. |
| Cloudflare R2 (Node) | @payloadcms/storage-s3 | Use the S3 adapter against R2’s S3-compatible endpoint. |
| Cloudflare R2 (Workers) | @payloadcms/storage-r2 | Native binding version, marked beta; for Workers environments rather than plain Node. |
| Azure Blob | @payloadcms/storage-azure | Configured with a container and connection string, with per-collection prefixes. |
| Google Cloud Storage | @payloadcms/storage-gcs | gcsStorage({ collections, bucket, options }). |
| Vercel Blob | @payloadcms/storage-vercel-blob | Token-based, with prefixes and client uploads. |
| Uploadthing | @payloadcms/storage-uploadthing | Configured through its own token. |
The collections map is the thing to read carefully: media: true enables the adapter for that collection, and media: { prefix: 'media' } groups its objects under a path. Most adapters set disableLocalStorage for the collections they handle — which is why “files are not appearing in my local uploads folder” is expected behaviour, not a bug, once an adapter is wired in.
Whatever you choose, keep it consistent with the deployment article’s rule: media is infrastructure the app references, not a directory the app owns.
9. The errors you will actually hit
| Symptom | Cause and fix |
|---|---|
| Upload succeeds, no resized variants exist | No imageSizes configured, or sharp is not installed and passed into the config |
| Focal-point selector never appears | It needs imageSizes or resizeOptions; with none configured there is nothing to focus |
| Files vanish after a redeploy | Local disk storage on an ephemeral host. Configure a storage adapter and treat disk as a dev-only fallback |
| Private file is readable by anyone with the URL | access.read returns true, or the storage collection bypasses Payload’s proxy with disablePayloadAccessControl |
| Admin thumbnails are broken after moving to object storage | adminThumbnail is a size name that no longer resolves to a reachable URL. Use the function form and return the real URL |
| Multipart upload fails with a boundary error | The Content-Type header was set manually. Let the browser generate it |
payload.create with a file fails | A relative filePath was used, or raw bytes were passed instead of a file object. Use an absolute path, or write the bytes out first |
A related file has alt undefined in the frontend | The upload field’s maxDepth was too low to populate the document, so only an ID came back |
10. Key takeaways and challenge
- An upload collection is a normal collection plus an
uploadconfig block: same access rules, same hooks, samerelationToreferencing from other collections. imageSizesis what makes a media library useful; without it you serve originals.sharpmust be installed and passed into the config for any of it to work.- Crop runs before resize, and the focal-point control only exists when sizes or resize options are configured.
disableLocalStorageis a storage switch, not a privacy switch. Privacy comes fromaccess.readplus signed downloads or your own authorized route.- Local API uploads take a
filePathor afileobject, not a raw buffer; REST uploads are multipart with the file underfileand metadata under_payload. - Storage adapters are separate official packages keyed by collection, and they are what keep media alive across deploys and restores.
- Read the adapter’s
collectionsmap carefully: enabling it for a collection is also what stops local writes.
Challenge: build a media collection with an alt field, three image sizes, adminThumbnail pointing at the smallest of them, and a read rule that allows anonymous reads. Then add a second, private collection for invoices whose read rule requires an authenticated user, wire an S3-compatible adapter for both with different prefixes, and write down — before you test it — what happens to an existing public file URL if you set disablePayloadAccessControl on the media collection and leave the invoice collection alone. Then add a filterOptions rule to the featured-image field of your Posts collection so only landscape images can be selected, and explain in one sentence why that constraint belongs in config rather than in a component.
Wiring uploads and storage into a Payload project and want a second opinion on the access rules around private files? Ampersand Academy runs one-to-one mentoring on TypeScript and Next.js backends — from Mahadhi, a development and digital marketing company.
How do I add file uploads to Payload CMS?
Turn any collection into an upload collection by giving it an upload config with upload true or an options object such as staticDir and imageSizes. Then reference it from other collections with a field of type upload and a relationTo pointing at that collection’s slug.
Does disableLocalStorage make my files private?
No. It only stops Payload writing files to local disk, and storage adapters set it for the collections they handle. Privacy comes from the upload collection’s access read rule, plus signed downloads or your own authorized route when files must never be public.
Why are no resized image variants generated?
Either the collection has no imageSizes entries, or sharp is not installed and passed into the base config. Payload uses sharp for resizing and cropping, and it is an optional dependency in the 3.x line.
Should I pass a file path or a file object to payload.create?
Both are documented: an absolute filePath for a file on disk, or a file object. If your bytes live in memory, write them to a file and use filePath, or convert them into a supported file representation rather than passing a raw buffer.
Where do uploaded files go in production?
Onto a storage adapter such as S3, R2, Azure Blob, Google Cloud Storage, Vercel Blob or Uploadthing. Local disk is fine for development, but on an ephemeral or multi-instance host a redeploy can remove media that the database still references.
How do I upload a file over REST?
Send a multipart POST to the upload collection’s endpoint, for example the media route, with the file under the key file and the rest of the document JSON encoded into the _payload key. Let the browser set the multipart content type and boundary.
How do I show the same image at different sizes on the frontend?
Read the generated sizes data from the upload document, such as the card or hero variant URL, instead of rebuilding filenames by hand. If the whole upload document comes back as an ID, raise depth on the query or check the upload field’s maxDepth.

