File Transformers
A file transformer is a small, ordered stage in Payload's upload pipeline. Transformers can:
- Process a file's bytes at upload time (resizing an image, converting a format, and so on)
- Serve a dynamically-transformed variant of an already-stored file at request time (for example, resizing an image on the fly based on query parameters)
Payload core owns transformer configuration, ordering, access control, and storage retrieval. Transformer packages own the actual byte manipulation. @payloadcms/transformer-sharp is Payload's official transformer, and covers the image-resizing behavior that shipped as part of core in Payload 3.x.
Configuring transformers
Transformers are configured once, globally, under upload.transformers in your Payload Config:
upload.transformers is the only transformer configuration surface — there is no per-collection or per-field transformer list. Each transformer decides, per request or per upload, which collections and MIME types it applies to.
Every transformer must declare a unique, non-empty slug. Payload throws a startup error if two transformers share a slug, if a transformer's mimeTypes array is empty or contains a malformed pattern, or if a declared capability (init, canTransform, transformFile, handleRequest) isn't a function.
Registering a transformer changes the file endpoint
As soon as upload.transformers contains any transformer, every request to a collection's file endpoint (/api/<collection>/file/<filename>) goes through the transformer-aware handler, even when no transformer ends up applying:
- The requested file must belong to an upload document. A file that exists on disk but has no matching document returns
404, where Payload 3.x would have served it. - For a file whose MIME type any transformer declares (every image, with
sharpTransformer()registered),access.readis called up to twice per request — see Access control. - A response produced by a transformer always carries Payload's CORS headers, which
modifyResponseHeaderscan't override. When no transformer applies, the original file is served with the usual headers.
MIME patterns
A transformer's mimeTypes array accepts:
- An exact value, e.g.
image/png - A category wildcard, e.g.
image/* - The universal wildcard,
*/*
How the pipeline runs
For both upload-time and request-time processing, Payload builds a fixed, ordered list of eligible transformers before running any of them:
- Filter to transformers that declare the capability being used (
transformFilefor uploads,handleRequestfor dynamic requests). - Filter to transformers whose
mimeTypesmatch the file's MIME type. - Call each remaining transformer's
canTransform(if defined) to decide whether it's eligible for this specific file/request. A transformer with nocanTransformis always eligible once it passes the MIME filter.
The result is a plan — a fixed list — computed before any transformer actually runs. Every eligible transformer then runs once, in the order it appears in upload.transformers, passing its result to the next:
continue— hand the (possibly-replaced) file/response to the next transformer in the pipeline. Returningcontinuewithout a replacement preserves the same object reference; nothing is copied.complete— stop the pipeline here and use this result. No later transformer runs.- Throwing — aborts the pipeline immediately. For dynamic requests, the error is logged and rethrown: an
APIErrorkeeps its own status, anything else becomes a500. For uploads, the error fails the upload with aFileUploadError, no document is committed, and no partial file is written. AcanTransformthat throws aborts planning the same way, before any stage runs.
Payload only ever keeps the current accumulator, never a history of every stage's output.
The canTransform capability
canTransform must be inexpensive and side-effect-free — it must not fetch the file's source, call an external service, or perform the transformation itself. It only decides eligibility. If you omit it, the transformer is eligible for every request/upload that already passed the MIME filter.
The transformFile capability (uploads)
transformFile is a one-file-in, one-file-out primitive — it never writes to storage itself. Payload's upload pipeline calls every eligible transformer's transformFile in declaration order, threading the result of one into the next, then persists whatever the pipeline produces. options is undefined for an ordinary upload.
Transformers only run on uploads whose full bytes are available. When a client uploads directly to cloud storage (clientUploads) on a collection with disableLocalStorage, Payload downloads the whole file only when upload.mimeTypes restricts the allowed types, a transformer other than sharpTransformer has a transformFile that is eligible for the upload (its mimeTypes match and its canTransform, if any, returns true), the request carries a crop or resize edit, the file is an animated image, or the collection has Sharp variants or image adjustments (resizeOptions, formatOptions, and so on). Otherwise the upload is saved without running any transformer.
The stored filename is based on the returned File's name. Its mimeType and extension come from the output bytes when their format can be detected, such as an image converted to WebP. Otherwise, as for text-like output like CSV or JSON, they come from the returned File's type and name. Set both on the File you return when your transformer changes the format.
The handleRequest capability (dynamic requests)
getSourceFile is a lazy, single-use getter, given to each stage separately. It performs no work until called, and calling it a second time in the same stage — including while the first call is still pending, or after it has rejected — throws. It returns the previous stage's response when there is one, and the stored file's bytes otherwise. This means:
- A transformer that only redirects, or that answers from a source it already has, never has to pay the cost of reading the stored file.
- The stored file is read at most once per request, and each stage reads the output of the one before it.
- A stage that calls
getSourceFilemust return aresponse— returningcontinuewithout one throws aTransformerContractError(a500), because the source it consumed would otherwise be lost.
Dynamic requests never write anything back to storage, and never trigger a document's own upload/delete hooks. A dynamic-transform request is a read-only view over an already-uploaded file.
Example: a multi-stage local transformer
Example: redirecting without fetching the source
A transformer that hands off to an external image-processing service never needs to read the stored file itself — it can complete with a redirect and skip getSourceFile entirely:
Access control
Ordinary file reads are unaffected by transformers: they use the same collection read access control that governs every other file request.
A dynamic-transform request — one for a file whose MIME type at least one configured handleRequest transformer declares — additionally sets req.fileTransform: true for the duration of the access.read call that decides whether the request may proceed. This is decided from mimeTypes alone, before any canTransform runs, so with sharpTransformer() registered every image request counts, even when dynamic resizing is off. This lets a collection distinguish "someone is asking for a transformed variant of this file" from an ordinary read:
req.fileTransform is true only during that access-control call. It is not set during canTransform, transformFile, or handleRequest. No transformer code runs until an access check has passed: if every check is denied, canTransform, getSourceFile, every transformer's handleRequest, and any external service a transformer would have called are all skipped entirely.
access.read may run a second time with req.fileTransform unset: when the transform-aware call is denied, or when no transformer ends up applying to the request. If the transform-aware call is denied but the ordinary read is allowed, canTransform then runs to decide the request: it is served as the original file when no transformer applies, and denied with 403 when one does. Whichever way the request is finally served — transformed or original — the matching access mode must allow it. Keep access.read cheap, since it can run twice per file request.
This access check runs before any transformer executes, and before Payload resolves which storage adapter or external provider would serve the source bytes. A denied request never reaches storage.
@payloadcms/transformer-sharp
@payloadcms/transformer-sharp is Payload's official Sharp-based transformer. Install it and register it once:
Upload-time image processing
Per-collection Sharp settings — variants (image sizes), resizeOptions, formatOptions, trimOptions, constructorOptions, withMetadata, crop, and focalPoint — are authored through sharpTransformer({ collections }), not on the collection's own upload config:
At startup, sharpTransformer writes a Sharp-agnostic projection of variants (name, admin, and generateImageName only), crop, and focalPoint back onto the collection's sanitized upload config, as upload.variants/.crop/.focalPoint. A crop or focalPoint set on sharpTransformer replaces the collection's own value; when it isn't set there, the collection's value is kept. So the Admin Panel, generated types, and the rest of core see the same sizes as before. Each generated size is stored on your documents under variants.<name>. variants exists only on the sanitized config: setting it (or the removed imageSizes) on the collection itself fails the build.
You can register more than one sharpTransformer (each with its own slug), for example to give different collections different dynamic-resize defaults. Each upload is processed by the instance whose collections lists that collection. A collection listed in the collections of more than one instance throws at startup.
See Variants for what each Sharp-specific image size option does, and Crop and Focal Point Selector for the Admin Panel behavior these settings control.
Dynamic (request-time) resizing
sharpTransformer can resize a stored image on the fly when it's requested with recognized query parameters, without storing the resized result. This is disabled by default: until you enable it, resize parameters are ignored and the original file is served. (Registering the transformer still changes the file endpoint in the ways described in Registering a transformer changes the file endpoint.)
Enable it with the dynamic option, either for every upload collection or only for the collections that need it:
A slug in dynamic.collections that isn't an upload-enabled collection throws at startup. For collections that aren't listed, resize parameters are ignored and the original file is served.
Once enabled, request a file with any of these parameters:
Parameter | Description |
|---|---|
| Target width, in pixels. A positive integer. |
| Target height, in pixels. A positive integer. |
| |
Recognized behavior:
- Presence of any recognized parameter routes the request through this transformer. Malformed values (a non-integer
width, for example) return400, not a fallback to the original file. - Repeated occurrences of the same parameter are invalid and return
400. withoutEnlargementon its own, withoutwidthorheight, returns400.- A request over
maxWidth,maxHeight, ormaxPixels(below) returns400rather than being clamped. Withfit: 'outside', or a single dimension, the limits are checked against the actual output size once the source is read. - Unrelated query keys (
draft,depth,where,prefix, and so on) are ignored — the request falls through as an ordinary read. - Specifying both
widthandheightcrops to that exact box, centered (fit: 'cover'), by default. - Specifying only one of
width/heightpreserves the original aspect ratio. - Images smaller than the requested size are upscaled by default. Pass
withoutEnlargement=trueto return the original size instead, or configure a different default (below). - The output keeps the source image's own format — there is no
format/quality query parameter in this version. - The stored file's own MIME type governs whether it's eligible.
sharpTransformerhandles JPEG, PNG, GIF, WebP, TIFF, and AVIF; this list isn't configurable. Other types (for example, JPEG XL) are served unchanged, without error. GETandHEADare both supported. ARangeheader on a recognized dynamic-resize request returns416— partial dynamic resizes are not supported.prefixis core-owned routing context, used to locate the stored document. It is never a resize parameter.
Passing an object enables dynamic resizing and configures the defaults for every dynamic-resize request on this transformer instance:
As with every transformer, dynamic-resize output is never written back to storage — it is generated for that one response and discarded.
Securing dynamic resizing
Every dynamic-resize request decodes the full source image and encodes a new one, and the output is not cached. Anyone who can read a file can ask for it at any size within your limits, so on a publicly readable collection an unbounded stream of distinct sizes costs you CPU and memory on every request. Before enabling it, especially on public collections:
- Enable it only where it's needed. Prefer
dynamic: { collections: [...] }overdynamic: true. - Restrict transformed reads in access control.
req.fileTransformistruewhileaccess.readdecides whether a dynamic-transform request may proceed (see Access control), and the request's query is available onreq.searchParams. Use it to require a signed-in user, or to allow only the sizes your frontend actually uses:
A denied transform request returns 403, and the source file is never fetched.
- Lower the limits to what you serve. Set
maxWidth,maxHeight, andmaxPixelsto your largest real rendition, and setwithoutEnlargement: trueso requests can't upscale small images.maxWidthandmaxHeightapply per frame, whilemaxPixelscounts every frame: animated GIF, WebP, and AVIF images are resized frame by frame, so a 44-frame image at 1000x1000 counts as 44,000,000 pixels. - Cache the output in front of Payload. Dynamic-resize responses don't set
Cache-Control. Add one with the collection'smodifyResponseHeaders, which also applies to dynamic-resize responses, and serve the endpoint through a CDN or reverse proxy that caches by full URL. A fixed set of allowed sizes (step 2) keeps the cache effective. Only mark responsespublicfor collections whose files are publicly readable, or a shared cache will serve access-controlled files to anyone. - Rate-limit at the edge. Payload doesn't rate-limit file requests. If the endpoint is public, apply rate limits in your CDN, load balancer, or reverse proxy.
Non-goals
The transformer boundary intentionally does not include:
- Caching. Every dynamic-transform request re-runs the pipeline. A future version may introduce a cache-key insertion point ahead of
handleRequest(derived from the collection, filename, and the recognized transform parameters), but no caching exists today. - CDN integration. Transformers don't know about, and don't configure, any CDN in front of Payload. Serving transformed output through a CDN is entirely up to your own infrastructure.
- A background jobs queue. All transformation happens synchronously, in the request/upload path. A future version may accept a job-shaped input for expensive, queueable transforms, but no such queue exists today.
None of the above is implemented as a stub or a partial feature — they are simply not built yet.
Was this page helpful?