Site icon Ampersand Tutorials

Payload CMS Uploads and Media Handling: Image Sizes, Storage and Access (2026)

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.

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:

// 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:

OptionWhat it does
staticDirLocal directory for the files. Defaults to the collection slug.
imageSizesGenerates resized image variants. Nothing is generated until you list sizes here.
adminThumbnailWhich thumbnail the admin list uses: a size name, or a function returning a URL.
mimeTypesRestricts what the picker accepts, for example image/*.
disableLocalStorageStops Payload writing to local disk. Storage adapters set this for their collections.
cropAdmin crop tool. On by default; false turns it off.
focalPointFocal-point selector. On by default, and only shown when imageSizes or resizeOptions exist.
formatOptionsSharp output format and options, for example WebP at a given quality.
resizeOptions / constructorOptions / trimOptionsSharp-level control over the resize pass.
withMetadataBoolean or function controlling the metadata written into the image.
displayPreviewWhether related upload fields show an inline preview of the file.
bulkUploadMulti-file upload from the collection list. Defaults to true.
filesRequiredOnCreateWhether a file must accompany a new document. Defaults to true.
pasteURLPasting a remote URL into the upload field. On by default; allowList enables server-side fetching from trusted patterns.
handlersCustom request handlers for file retrieval.
hideFileInputOnCreate, hideRemoveFileHide 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:

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 optionWhat it does
relationToOne upload collection slug, or several for a polymorphic field. Required.
hasManyAllow multiple referenced files. Pair it with minRows / maxRows to bound the list.
filterOptionsA query, or a function returning one, that limits which documents can be selected.
maxDepthCaps how deep the related upload document is populated on reads.
displayPreviewOverrides 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:

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.

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,
        },
      },
    }),
  ],
})
ServicePackageNotes
AWS S3@payloadcms/storage-s3s3Storage({ collections, bucket, config }); supports prefixes, clientUploads and signedDownloads.
Cloudflare R2 (Node)@payloadcms/storage-s3Use the S3 adapter against R2’s S3-compatible endpoint.
Cloudflare R2 (Workers)@payloadcms/storage-r2Native binding version, marked beta; for Workers environments rather than plain Node.
Azure Blob@payloadcms/storage-azureConfigured with a container and connection string, with per-collection prefixes.
Google Cloud Storage@payloadcms/storage-gcsgcsStorage({ collections, bucket, options }).
Vercel Blob@payloadcms/storage-vercel-blobToken-based, with prefixes and client uploads.
Uploadthing@payloadcms/storage-uploadthingConfigured 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

SymptomCause and fix
Upload succeeds, no resized variants existNo imageSizes configured, or sharp is not installed and passed into the config
Focal-point selector never appearsIt needs imageSizes or resizeOptions; with none configured there is nothing to focus
Files vanish after a redeployLocal 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 URLaccess.read returns true, or the storage collection bypasses Payload’s proxy with disablePayloadAccessControl
Admin thumbnails are broken after moving to object storageadminThumbnail 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 errorThe Content-Type header was set manually. Let the browser generate it
payload.create with a file failsA 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 frontendThe upload field’s maxDepth was too low to populate the document, so only an ID came back

10. Key takeaways and challenge

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.

Exit mobile version