payload-images
On-demand image optimization for Payload CMS, perfectly sized and focal-cropped at any screen size.
@pro-laico/payload-images brings a Sanity-style image pipeline
to Payload: upload a picture once and ask for any size, crop, or format with a URL. The plugin
renders it on the fly the first time it's requested, then caches it forever. The result is images
that are never bigger than they need to be, and trivial to resize, recrop, reformat, and swap
out, whether from the admin panel (drag the focal point, replace the file) or straight from your code
(point a <ResponsiveImage> or a virtual srcset at any aspect ratio). Pair it with
@pro-laico/payload-seed and even your seeded media, focal point and
all, is declared in code and ready to serve. Think of it as the image-optimization layer next/image
should give you but doesn't: art-directed focal cropping, AVIF/WebP negotiation, and a durable cache,
all driven from your CMS.
pnpm add @pro-laico/payload-imagesWhat's included
A Sanity-style image pipeline, driven from your CMS:
- Never bigger than it needs to be: every layout requests the exact size, crop, and format it needs; the endpoint renders it once on demand, then caches it forever.
- Focal-point cropping: mark the subject once and every crop, at any aspect ratio, stays centered on it — no more lopped-off heads.
- Modern formats, negotiated: AVIF / WebP served to browsers that accept them, with a graceful fallback.
- Resize, recrop, reformat from anywhere: point a
<ResponsiveImage>or a virtualsrcsetat any ratio in code, or drag the focal point / replace the file in the admin. - Self-busting cache + declarative seeding: content-addressed URLs refresh themselves when a file or focal point changes, and seeded media (focal point and all) is declared in code through
@pro-laico/payload-seed.
Sample images rendered in the frontend by the images-sandbox example repo
Quickstart
Built for Next.js. The transform endpoint persists variants with Next's after() and the cache
story leans on Next's caching, so this plugin targets Payload running in a Next.js app (the default
Payload setup). It isn't framework-agnostic.
Add the plugin
import { buildConfig } from 'payload'
import sharp from 'sharp'
import { imagesPlugin } from '@pro-laico/payload-images'
const serverURL = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000'
export default buildConfig({
sharp, // required: the endpoint resizes/crops with Sharp
serverURL, // used to read originals from cloud/relative storage
plugins: [imagesPlugin()],
})Already have a media/images collection? Pass imagesPlugin({ extendCollection: 'media' })
to add the pipeline to your existing upload collection instead of creating a second one. See
Extending a collection.
Keep Sharp out of the bundle
Sharp ships a native binary, so tell Next not to bundle it (Turbopack and webpack both need this):
// next.config.ts
const nextConfig = { serverExternalPackages: ['sharp'] }
export default nextConfigGenerate the admin import map
The plugin registers custom admin components (the focal-point picker and Purge variants button), so regenerate the import map:
pnpm payload generate:importmapPlugin options
imagesPlugin(options?) is the single entry point: one call registers the two collections, the
transform + purge endpoints, and the admin UI (all detailed below). It's zero-config.
Pass options to customize. The Reference tab is the interactive view (transform expands to its
nested options); TypeScript is the same shape in code, every option at its default.
enabledbooleandefault trueWhen false, registers nothing. This is "not installed", not "paused": on SQL adapters, turning it off for an existing project produces a migration that drops the image tables and their data.
extendCollectionstringSlug of an existing upload collection to add the pipeline to (focal UI, variants join, purge hooks, upload.focalPoint), instead of creating the images collection. The target must be an upload collection (you own its upload config).
imagesOverridesPartial<CollectionConfig>A Payload CollectionConfig passthrough (any collection-level key, not a fixed plugin set), deep-merged onto the Images collection: upload/access/admin merged, fields/hooks appended. Don't redeclare a base field name like alt or variants. With extendCollection, merged onto the target instead.
generatedImagesOverridesPartial<CollectionConfig>A Payload CollectionConfig passthrough, merged onto the hidden generated-images (variant cache) collection.
pixelStepnumber | number[]default 50Project-wide srcset widths. A number is the step (bigger = fewer widths and cached variants); an array is an explicit ladder, e.g. [200, 450, 750, 1200, 2000], best kept on multiples of 50, since the endpoint rounds requested sizes to a 50px grid. maxDimension caps the top.
transformTransformEndpointConfig | falsedefault {}
Config for the on-demand transform + purge endpoints. Pass false to register neither.
sourceSlugstringdefault 'images'Slug of the source upload collection to transform.
variantSlugstringdefault 'generated-images'Slug of the collection that caches generated variants.
cdnCacheControlbooleandefault trueAlso emit CDN-Cache-Control / Vercel-CDN-Cache-Control headers (public images only).
maxDimensionnumberdefault 4096Hard ceiling on either output dimension.
defaultQualitynumberdefault 75Encode quality used when a request omits q.
qualityRange[number, number]default [40, 95]Allowed quality range; requested values clamp into it.
defaultFormatFormatdefault 'auto'Output format when a request omits fmt (auto negotiates from Accept).
formatsFormat[]default ['auto','avif','webp','jpeg','png']Output formats the endpoint may emit.
preferAvifbooleandefault falseAuto-negotiate AVIF when accepted. Off by default: AVIF encodes far slower, so fmt=auto serves WebP for a fast cold path (AVIF stays available on explicit fmt=avif).
maxInputPixelsnumberdefault 100000000Max source pixels Sharp will decode: a decompression-bomb guard that also caps per-transform memory (~100MP ≈ 400MB).
maxConcurrencynumberdefault cpus - 1Max concurrent Sharp transforms in this process (or IMAGES_TRANSFORM_CONCURRENCY).
sharpConcurrencynumberdefault 1Per-image libvips thread cap (or IMAGES_SHARP_CONCURRENCY); 0 = CPU cores. Defaults to 1 for serverless safety.
focalUIbooleandefault trueRender the focal-point picker + ratio-preview field and the Purge variants button.
previewRatiosstring[]default ['16:9','9:16','1:1','4:3','3:2','21:9']Aspect ratios shown as preview tiles in the focal-point field.
virtualFieldsbooleandefault true (false when transform: false)Add computed src/srcset/placeholderURL/thumbnailURL fields to every read, so optimized URLs ride along in REST/GraphQL/Local-API responses and relationship population. Defaults off when the transform endpoint is disabled (the URLs would 404); pass true explicitly to force them anyway.
localizeAltbooleandefault falseMark the alt field localized (requires Payload localization). Ignored with extendCollection.
mimeTypesstring[]default ['image/avif','image/webp','image/jpeg','image/png']Accepted upload mime types for the images collection; defaults to the raster formats the transform pipeline can process. Widen it (e.g. add 'image/svg+xml') or narrow it, but the endpoint only meaningfully resizes/crops raster images; non-raster uploads are stored and served as-is. Ignored with extendCollection (you own that collection's upload.mimeTypes).
foldersbooleandefault falseEnable Payload's native folder organization on the managed collection (the created images, or the extendCollection target) so editors can organize a large library.
maxOriginalSizenumberCap the stored original's longest edge (px), applied once on upload. Off by default; your original stays untouched (the collection can double as original storage). Set it only to bound storage. Ignored with extendCollection.
placeholderfalse | { width?, quality?, format?, maxWidth? }default { width: 24, quality: 40, format: 'webp', maxWidth: 64 }Inline LQIP placeholder for <ResponsiveImage>: a tiny, faithful (per ratio + focal) image generated server-side, base64-inlined behind the real image, painted instantly with zero network. width/quality/format set the defaults; maxWidth caps per-read overrides from the untrusted external door (clamped + snapped to /8). Keep widths in 24–64, since the LQIP inlines as base64 in every response. Pass false to disable placeholders project-wide.
import { imagesPlugin } from '@pro-laico/payload-images'
// Every option at its default. This is the zero-config behaviour, written out.
imagesPlugin({
enabled: true,
// extendCollection: 'media', // optional, no default: add the pipeline to an existing collection instead of creating `images`
// imagesOverrides: { /* … */ }, // optional, no default: deep-merged onto the images collection
// generatedImagesOverrides: { /* … */ }, // optional, no default: merged onto the variant-cache collection
pixelStep: 50,
transform: {
sourceSlug: 'images',
variantSlug: 'generated-images',
cdnCacheControl: true,
maxDimension: 4096,
defaultQuality: 75,
qualityRange: [40, 95],
defaultFormat: 'auto',
formats: ['auto', 'avif', 'webp', 'jpeg', 'png'],
preferAvif: false,
maxInputPixels: 100_000_000,
// maxConcurrency: cpus - 1, // default (or IMAGES_TRANSFORM_CONCURRENCY)
sharpConcurrency: 1,
},
focalUI: true,
previewRatios: ['16:9', '9:16', '1:1', '4:3', '3:2', '21:9'],
virtualFields: true,
localizeAlt: false,
mimeTypes: ['image/avif', 'image/webp', 'image/jpeg', 'image/png'],
folders: false,
// maxOriginalSize: 4096, // optional, no default: off (the original is kept untouched)
placeholder: { width: 24, quality: 40, format: 'webp', maxWidth: 64 },
})Collections
The plugin registers two collections under an Assets admin group: one you upload to, one that caches what gets generated. Both accept PNG, JPEG, WebP, and AVIF.
images
The source upload, and the only one you touch. It stores the original untouched (no pre-generated
sizes by default) and keeps Payload's native focal point; every rendered size is produced on demand
by the endpoint and recorded in generated-images. Read is public; writes are
logged-in-admin only.
Fields
Prop
Type
Hooks
Prop
Type
generated-images
The hidden, durable variant cache that you never touch directly. The transform endpoint writes one
upload doc here per (source, settings, focal) combination, keyed by a unique cacheKey and related
back to its source. It's an upload collection, so each generated variant is stored through whatever
storage adapter you've configured. It's surfaced on images via the variants join and purged by that
collection's change/delete hooks.
Fields
Prop
Type
Hooks: none. Variants are derived and disposable, so the collection carries no revalidation hooks (busting cache tags on every cache-miss create would be pure churn).
Extending a collection
Most projects already have a media (or images) upload collection. Point the plugin at it
with extendCollection and it adds the pipeline (the focal UI, the variants join, the purge
hooks, and upload.focalPoint) to that collection, instead of creating a second one:
imagesPlugin({ extendCollection: 'media' })The transform endpoint then serves /api/img/:id for media docs, and the variant cache
relates back to media. The target must be an upload collection (the plugin throws a clear
error otherwise); your collection keeps full ownership of its own upload config, including
any imageSizes.
API URLs
Every read of an images doc also carries optimized URLs as virtual fields, computed on read
and never stored, so any consumer gets ready-to-use URLs with no client code and no knowledge of
/api/img. The component and URL builders below are for JS/React; this is for
everyone else: a mobile app, a GraphQL client, a plain fetch, an OG/RSS generator:
// GET /api/images/<id> (or a populated page.heroImage)
{
"id": "...", "alt": "...", "width": 2400, "height": 1600,
"src": "https://site.com/api/img/<id>?w=1280&fit=cover&q=75&fmt=auto&v=…",
"srcset": "https://site.com/api/img/<id>?w=400&h=267&fit=cover&q=75&fmt=auto&v=… 400w, …",
"placeholderURL": "https://site.com/api/img/<id>?w=32&h=21&fit=cover&q=40&fmt=auto&v=…",
"thumbnailURL": "https://site.com/api/img/<id>?w=160&h=160&fit=cover…"
}The URLs are absolute when serverURL is set (relative otherwise), hidden in the admin, and
flow through relationship population: a populated page.heroImage already carries srcset
/ placeholderURL, ready to render. Population is kept lean via defaultPopulate: referenced
images return these renderable fields and not the variants cache join (which would cost an
extra query per image). Disable with virtualFields: false. (A blurDataURL, an on-demand inline
LQIP, sits on the doc too, but stays null until a read opts in; see Placeholder.)
One cost to know: a wide original at the default pixelStep: 50 emits dozens of srcset URLs
(several KB of string) on every read and population — raise pixelStep or pass an explicit ladder
if response size matters.
The resolved config (slugs + options) is stashed on config.custom.payloadImages, so decoupled
tooling (an OG/sitemap generator, a CDN-purge script, a migration) can read it from just
payload, with no import.
Endpoint
Everything optimized is served by one public endpoint, /api/img/:id.
You normally never write these URLs yourself (<ResponsiveImage> and the virtual fields do
it for you), but seeing them is the fastest way to understand the rest:
GET /api/img/<id>?w=600&h=600&fit=cover # focal cover-crop, auto format
GET /api/img/<id>?w=1200&h=630&fit=cover&fmt=avif # an OG-sized AVIFA component like <ResponsiveImage image={img} aspectRatio="16:9" /> is just a URL builder over
this endpoint: it turns its props into one URL per srcset width, deriving each h from the ratio:
/api/img/<id>?w=640&h=360&fit=cover&q=75&fmt=auto&v=1a2b3c 640w
/api/img/<id>?w=1280&h=720&fit=cover&q=75&fmt=auto&v=1a2b3c 1280wParams
| Param | Meaning |
|---|---|
w / h | Target width / height in px (at least one required). Snapped to the dimensionStep grid and clamped to maxDimension. |
ar | Aspect ratio (16:9, 16/9, 1.78); derives the missing dimension. |
fit | cover (focal crop, default) · contain · inside · outside · fill. |
q | Quality, bucketed to multiples of 5 and clamped to qualityRange. |
fmt | auto (negotiate from Accept) · avif · webp · jpeg · png. |
v | Cache-busting token (derived from filename + focal); the server ignores it. |
The first request for a size generates it with Sharp and streams it same-origin, then persists the variant after responding; later identical requests stream the stored copy.
Caching & abuse limits
The transform endpoint is public-facing, so it's bounded on several fronts:
- Variant cache (
generated-images). The first request for a size generates and stores it; later requests stream the stored copy. Replacing the file or moving the focal point purges that image's stale variants (the change/delete hooks); the Purge variants button andPOST /api/img/purge/:idclear them on demand. - Browser/CDN cache. Transform responses are immutable and each URL carries a
vtoken from the source's filename + focal point, so replacing the file or moving the focal makes already-cached responses refetch; a metadata-only edit (alt) doesn't. - Access control. Source reads run with the collection's access rules. A source you can't
read returns
404; a non-public source is servedprivatewith no shared CDN caching. The purge endpoint requires a logged-in user who can read that source. - Bounded variant space (DoS). Requested dimensions snap to the
dimensionStepgrid and quality buckets to a small set, both clamped tomaxDimension, so a caller can't spin up unbounded variants withw=1,2,3,…. Output never upscales past the source. - Bounded work.
maxInputPixelscaps how many pixels Sharp decodes (a decompression-bomb + memory guard), and a concurrency gate keeps a cold page that requests many sizes from saturating CPU. - SSRF + path traversal. Local reads stay inside the collection's
staticDir; cloud/relative reads self-fetch with redirects disabled, a 15s timeout, a 64MB cap, and refuse loopback / private / link-local hosts (while still allowing your configured origin).
There's no per-URL request signing; generation is gated by the bounds above rather than a
shared secret. For fully untrusted traffic, lower dimensionStep / maxDimension /
maxInputPixels and put a rate limiter or CDN in front.
<ResponsiveImage>
A plain <img> whose srcset points at the transform endpoint, so the browser fetches the one size it
needs. Not next/image: an async server component (no client JS), imported from components/image.
Component = what you write; Rendered HTML = a single <img> with a full srcset (one URL per
width, capped at the source) and an inline LQIP painted as its own background-image:
import config from '@payload-config'
import { getPayload } from 'payload'
import { ResponsiveImage } from '@pro-laico/payload-images/components/image'
export async function Hero({ id }: { id: string }) {
const payload = await getPayload({ config })
const image = await payload.findByID({ collection: 'images', id, depth: 0 })
return (
<ResponsiveImage
image={image}
aspectRatio="16:9"
sizes="(max-width: 768px) 100vw, 50vw"
className="rounded-xl" // Tailwind (or any className) → the <img>
/>
)
}<!-- one element: your className + the layout plumbing + the inline LQIP, all on the <img> -->
<img
class="rounded-xl"
src="/api/img/64a…?w=1280&h=720&fit=cover&q=75&fmt=auto&v=1a2b3c"
srcset="/api/img/64a…?w=50&h=28&fit=cover&q=75&fmt=auto&v=1a2b3c 50w,
/api/img/64a…?w=100&h=56&fit=cover&q=75&fmt=auto&v=1a2b3c 100w,
/api/img/64a…?w=150&h=84&fit=cover&q=75&fmt=auto&v=1a2b3c 150w,
… (every 50px up to the source)
/api/img/64a…?w=2400&h=1350&fit=cover&q=75&fmt=auto&v=1a2b3c 2400w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="…"
width="2400"
height="1350"
loading="lazy"
fetchpriority="auto"
decoding="async"
style="display:block; width:100%; height:auto; aspect-ratio:1.7777777777777777; object-fit:cover;
background-image:url(data:image/webp;base64,UklGR…); background-size:cover; background-position:center" />The srcset steps by pixelStep up to the source width (default 50), or follows an
explicit ladder if you pass an array. It's built server-side, the same for every image; maxDimension caps
the top.
Pass the populated doc (the findByID result) and it reads width/height (no layout shift),
alt, the srcset's source-width cap, and the v cache-bust token off it. No focal point needed: the
endpoint crops from the doc server-side.
Or a bare id:
<ResponsiveImage image="64a1f2c…" aspectRatio="16:9" sizes="50vw" />Degraded: dims guessed from the ratio, empty alt, no v, srcset climbs to the 4096 ceiling. Prefer
the populated doc; you usually have one.
Set sizes to how big the image actually renders. Default 100vw; leave it on an image in a
narrow column and the browser over-fetches (the next/image trap). Give it the real size, e.g.
sizes="(max-width: 768px) 100vw, 33vw".
See MDN: responsive images.
For a full-bleed hero, or any element that sets its own height, pass fill:
<div style={{ position: 'relative', height: '100vh' }}>
<ResponsiveImage image={image} fill sizes="100vw" />
</div>Custom API route or Next basePath? Transform URLs default to /api/img. Changed Payload's
routes.api (or set a Next basePath)? Pass path once via a wrapper:
const Image = (p: ResponsiveImageProps) => <ResponsiveImage path="/myapi/img" {...p} />.
Props
imagestring | number | docrequiredA bare id, or a populated doc (for natural dims, alt, and the cache-bust version).
altstringAlt text. Falls back to the doc's alt, then empty.
sizesstringdefault '100vw'The img sizes attribute; set it to how big the image actually renders.
aspectRationumber | stringRender ratio (16/9 | '16:9'); derives each srcset height. Falls back to the doc's natural ratio. Ignored with fill.
fillbooleandefault falseCover-fill a positioned parent that sets its own height (full-bleed hero, slide) instead of an aspect-ratio box.
qualitynumberdefault 75Encode quality baked into each URL.
fitFitdefault 'cover'Resize fit. cover focal-crops.
formatFormatdefault 'auto'Output format (auto negotiates from Accept).
sourceWidthnumberOverride the source intrinsic width that caps the srcset (else read from a populated doc).
loading'lazy' | 'eager'default 'lazy'Native <img> loading. Set 'eager' for an above-the-fold hero.
fetchPriority'high' | 'low' | 'auto'default 'auto'Native <img> fetchpriority. Set 'high' for the LCP image.
decoding'async' | 'auto' | 'sync'default 'async'Native <img> decoding hint.
classNamestringApplied to the <img>; size / space / round it here.
styleCSSPropertiesMerged onto the <img>'s style.
baseUrlstringdefault '' (same-origin)Absolute base for the generated URLs (OG / email contexts).
pathstringdefault '/api/img'Transform endpoint base; set only for a custom API route / Next basePath.
configSanitizedConfigPayload config used to resolve the project pixelStep + placeholder settings and generate the inline LQIP. Rarely needed: without it the component uses the config the plugin stashed at init, falling back to the @payload-config alias (which, from a published package, needs transpilePackages: ['@pro-laico/payload-images']).
versionstringExplicit cache-bust v token; overrides the one derived from the doc.
placeholderboolean | number | { width?, quality? }default true (project config)Inline LQIP placeholder. false disables it; a number sets the LQIP width in px; an object tunes { width, quality }. Keep widths small (24–64 is the sweet spot); it's base64-inlined in every response. Supersedes blur.
blurbooleandefault trueDeprecated alias for placeholder (blur={false} to disable).
dataAttributesRecord<string, string>Extra attributes (e.g. data-*) spread onto the rendered img element.
import { ResponsiveImage } from '@pro-laico/payload-images/components/image'
// Every prop at its default. `image` is the only required one.
<ResponsiveImage
image={image}
sizes="100vw"
fill={false}
quality={75}
fit="cover"
format="auto"
loading="lazy"
fetchPriority="auto"
decoding="async"
placeholder // on by default (24px); = false to disable, = 48 / { width, quality } to size
baseUrl="" // same-origin
path="/api/img"
// optional, no default:
// alt="A lighthouse at dusk"
// aspectRatio="16:9"
// sourceWidth={2400}
// className="rounded-xl"
// style={{ borderRadius: 8 }}
// config={config} // resolve pixelStep for a non-aliased setup
// version="1a2b3c"
// dataAttributes={{ 'data-test': 'hero' }}
/>Placeholder
No pop-in: a blurred preview paints from the first render, covered when the real image loads.
It's an inline LQIP: a tiny (default 24px), faithful crop (generated at your exact ratio
- focal) resolved to a base64 data-URI and painted as the
<img>'s ownbackground-image. Inline in the HTML, so it shows on the first frame with zero network; the real pixels cover it on load. Native swap, no JS, no<head>script. On for every image (eager too, since it's inline and doesn't count as the LCP element); skipped for a bare id. Disable withplaceholder={false}per-image orimagesPlugin({ placeholder: false })project-wide.
Sizing. 24px suits most images; a large hero can take a bigger blur, so pass a width:
<ResponsiveImage image={hero} placeholder={48} /> // 48px LQIP
<ResponsiveImage image={hero} placeholder={{ width: 48, quality: 50 }} />Keep it small: the LQIP inlines in every response (~0.6 KB @24 → ~3–4 KB @64), so 24–64 is the
sweet spot. The component honors whatever you pass (no maxWidth clamp, just a 256px sanity guard),
so reserve the big ones for heroes.
Non-React consumers can pull the same LQIP: set req.context.lqip = { ar, fit, width?, quality? }
on a read (or send X-LQIP: 16/9; w=48) and the doc returns a blurDataURL data-URI, a no-op on
every other read. It's untrusted, so width is clamped to placeholder.maxWidth (default 64)
and snapped to /8.
OG images & raw URLs
A <ResponsiveImage>-only app still needs a plain URL string now and then, whether for a social/OG
image, a CSS background-image, or an email, anywhere a component can't go. That's what getImageUrl
(from utils/urls) is for. The classic case is a social image in Next's generateMetadata:
import type { Metadata } from 'next'
import { getImageUrl } from '@pro-laico/payload-images/utils/urls'
export async function generateMetadata({ params }): Promise<Metadata> {
const page = await getPage(params.slug) // your data fetch
const image = page.heroImage // a populated `images` doc
const url = getImageUrl(image, { width: 1200, aspectRatio: '1200/630' }) // string | null (null without an id)
return {
openGraph: {
images: url ? [{ url, width: 1200, height: 630 }] : [],
},
}
}getImageUrl defaults baseUrl to NEXT_PUBLIC_SERVER_URL, so the URL comes out absolute (social
crawlers need that) with nothing to pass, focal-cropped + versioned like any transform URL. Pass an
explicit baseUrl to override, or baseUrl: '' for a relative one.
For the common sizes you don't even need getImageUrl: every doc read already carries virtual URL
fields (image.src, image.srcset, image.placeholderURL, image.thumbnailURL), so a card or a
default OG can just use image.src. Reach for getImageUrl only when you need a specific size or
ratio the presets don't cover, like OG's 1200×630; buildSrcset / buildVariantUrl are there for
a hand-rolled <img> / <picture>.
Focal-point cropping
Optimization is only half the job: a 16:9 hero and a 1:1 thumbnail of the same photo need different crops, and a naive center-crop lops off heads. So you mark the focal point (the part that must stay in frame) once per image, and the endpoint crops around it at every aspect ratio.
- Set it once. The
imagescollection ships Payload's focal-point picker plus a ratio-preview field, so an editor drags the focus onto the subject and sees how it crops at common ratios. - It rides along automatically. The endpoint reads the focal point from the document and crops
server-side on any
fit=coverrequest, so<ResponsiveImage>, the virtual URLs, and raw endpoint calls all honor it with nothing extra to pass. - Move it and stale crops refresh. Changing the focal point purges that image's cached variants
and bumps its
vtoken, so anything already served refetches the new crop.
Custom focal point setter component
How it works
The endpoint, the cache, and the content-addressed URLs fit together like this.
The transform pipeline
The transform endpoint (/api/img/:id) takes an id plus size / crop / format params, renders that
variant once with Sharp, and persists it with Next's after() so the response isn't blocked — every
later request for the same URL serves the cached bytes. URLs are content-addressed by a v token,
a hash of the source's filename and focal point, so a variant URL is immutable and safe to cache
forever, while the collection's change / delete hooks drop the variants a new file or focal point made
stale. Nothing is pre-generated: the hidden generated-images collection fills in lazily, one
requested size at a time.
Revalidation
The optimized images revalidate themselves, with nothing to configure. Because every URL is content-addressed, replacing the file or moving the focal point changes the URL, so browsers and CDNs fetch the updated image automatically (no manual purge) while the stale variants are dropped by the hooks.
The one thing the plugin doesn't touch is your Next.js page cache, but that isn't
image-specific. A page that cached an image doc keeps serving the old URL until you revalidate that
route, exactly as it would for any Payload content. Handle it however you already do (a
revalidatePath / revalidateTag in an afterChange hook, or a dynamic route). There's nothing
extra to add for images.
Seeding
Because images is a plain Payload upload collection, it seeds natively through
@pro-laico/payload-seed — no special provider needed. You seed it
like any other collection: defineSeed('images', …), with each record carrying its
source file on _file via the file() token, and referenced from a page with ref('images', …):
import { defineSeed, seedPlugin } from '@pro-laico/payload-seed'
import { imagesPlugin } from '@pro-laico/payload-images'
// src/seed/images.ts — each doc carries its file on `_file`; focal points drive the on-demand crops
const images = defineSeed('images', ({ file }) => [
{
_key: 'hero',
_file: file('hero.jpg'), // seed-assets/images/hero.jpg
alt: 'Hero',
focalX: 78,
focalY: 32,
},
])
// src/seed/pages.ts — reference a seeded image by ref (an upload-field relationship)
const pages = defineSeed('pages', ({ ref }) => [
{
_key: 'home',
title: 'Home',
heroImage: ref('images', 'hero'),
},
])
plugins: [
imagesPlugin(),
seedPlugin({ definitions: [images, pages], assetsDir: 'seed-assets' }),
]alt, focalX, and focalY are ordinary record fields, type-checked against the images
collection. focalX/focalY set the upload's focal point, so the seeded images crop to the right
subject the moment the transform endpoint serves them. See the
examples/images-sandbox app, a full working
setup with a visual gallery page that renders every seeded image focal-cropped at multiple
aspect ratios.
With imagesPlugin({ folders: true }), Payload's folders seed the same way — they're ordinary
docs in the hidden payload-folders collection, and each image references its folder like any
relationship:
// Folders first (seeded like any collection), then file each image into one:
const folders = defineSeed('payload-folders', () => [
{ _key: 'projects', name: 'Projects', folderType: ['images'] },
])
const images = defineSeed('images', ({ file, ref }) => [
{ _key: 'hero', _file: file('hero.jpg'), alt: 'Hero', folder: ref('payload-folders', 'projects') },
])The examples/service-co app seeds its whole library into Site / Services / Projects / Team
folders this way.
Gotchas
A few things to keep in mind:
- Leave
sizesat its100vwdefault and the browser over-fetches. The classicnext/imagetrap applies here too: an image in a narrow column still downloads at viewport width. Setsizesto how big the image actually renders, e.g.sizes="(max-width: 768px) 100vw, 33vw". enabled: falsemeans "not installed", not "paused". On SQL adapters, turning it off for an existing project produces a migration that drops the image tables — including every cached variant.- Sharp must stay out of the bundle. The plugin needs
sharpon the Payload config andserverExternalPackages: ['sharp']in next.config (Turbopack and webpack both) — it ships a native binary that breaks when bundled. - The transform endpoint is public and unsigned. It's bounded by design (grid-snapped sizes,
quality buckets,
maxDimension/maxInputPixels, a concurrency gate), but for fully untrusted traffic tighten those bounds and put a rate limiter or CDN in front. - Image URLs self-bust; your page cache doesn't. Replacing a file or moving a focal point changes
the
vtoken and purges stale variants, but a cached page keeps serving the old URL until you revalidate that route — exactly as for any other Payload content. - A bare id degrades
<ResponsiveImage>. Dimensions are guessed from the ratio,altis empty, there's novtoken, and the srcset climbs to the 4096 ceiling. Pass the populated doc; you usually have one. extendCollectionhands you the upload config. The plugin adds the pipeline to your collection, butmimeTypes,localizeAlt, andmaxOriginalSizeare ignored there — that collection'supload(and itsimageSizes) stays yours to manage.
Exports
| Export | From | What |
|---|---|---|
imagesPlugin | @pro-laico/payload-images | The Payload plugin; put imagesPlugin() in your config's plugins. |
ResponsiveImage | @pro-laico/payload-images/components/image | The responsive <img> async server component. |
getImageUrl | @pro-laico/payload-images/utils/urls | Build one transform URL for an image (an id or a populated doc): OG tags, CSS backgrounds, emails. |
buildSrcset | @pro-laico/payload-images/utils/urls | Build a responsive srcset + default src for an image id. |
buildVariantUrl | @pro-laico/payload-images/utils/urls | Build a single transform URL for an id at a given width. |
deriveVersion | @pro-laico/payload-images/utils/urls | Compute the cache-busting v token from a source doc (filename + focal point). |
FocalPreview | @pro-laico/payload-images/admin/focalPreview | Admin focal-point picker + ratio-preview field (wired via the import map when focalUI is on). |
PurgeVariants | @pro-laico/payload-images/admin/purgeVariants | Admin Purge variants button (wired via the import map when focalUI is on). |