Payload Plugins
Plugins

payload-images

On-demand image optimization for Payload CMS, perfectly sized and focal-cropped at any screen size.

For AI / LLMs: View Markdown

@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-images

What'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 virtual srcset at 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 repoSample 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 nextConfig

Generate 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:importmap

Plugin 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 true

When 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.

extendCollectionstring

Slug 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 50

Project-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 true

Also emit CDN-Cache-Control / Vercel-CDN-Cache-Control headers (public images only).

maxDimensionnumberdefault 4096

Hard ceiling on either output dimension.

defaultQualitynumberdefault 75

Encode 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 false

Auto-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 100000000

Max source pixels Sharp will decode: a decompression-bomb guard that also caps per-transform memory (~100MP ≈ 400MB).

maxConcurrencynumberdefault cpus - 1

Max concurrent Sharp transforms in this process (or IMAGES_TRANSFORM_CONCURRENCY).

sharpConcurrencynumberdefault 1

Per-image libvips thread cap (or IMAGES_SHARP_CONCURRENCY); 0 = CPU cores. Defaults to 1 for serverless safety.

focalUIbooleandefault true

Render 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 false

Mark 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 false

Enable Payload's native folder organization on the managed collection (the created images, or the extendCollection target) so editors can organize a large library.

maxOriginalSizenumber

Cap 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 AVIF

A 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 1280w

Params

ParamMeaning
w / hTarget width / height in px (at least one required). Snapped to the dimensionStep grid and clamped to maxDimension.
arAspect ratio (16:9, 16/9, 1.78); derives the missing dimension.
fitcover (focal crop, default) · contain · inside · outside · fill.
qQuality, bucketed to multiples of 5 and clamped to qualityRange.
fmtauto (negotiate from Accept) · avif · webp · jpeg · png.
vCache-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 and POST /api/img/purge/:id clear them on demand.
  • Browser/CDN cache. Transform responses are immutable and each URL carries a v token 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 served private with 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 dimensionStep grid and quality buckets to a small set, both clamped to maxDimension, so a caller can't spin up unbounded variants with w=1,2,3,…. Output never upscales past the source.
  • Bounded work. maxInputPixels caps 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 | docrequired

A bare id, or a populated doc (for natural dims, alt, and the cache-bust version).

altstring

Alt 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 | string

Render ratio (16/9 | '16:9'); derives each srcset height. Falls back to the doc's natural ratio. Ignored with fill.

fillbooleandefault false

Cover-fill a positioned parent that sets its own height (full-bleed hero, slide) instead of an aspect-ratio box.

qualitynumberdefault 75

Encode quality baked into each URL.

fitFitdefault 'cover'

Resize fit. cover focal-crops.

formatFormatdefault 'auto'

Output format (auto negotiates from Accept).

sourceWidthnumber

Override 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.

classNamestring

Applied to the <img>; size / space / round it here.

styleCSSProperties

Merged 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.

configSanitizedConfig

Payload 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']).

versionstring

Explicit 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 true

Deprecated 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 own background-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 with placeholder={false} per-image or imagesPlugin({ 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 images collection 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=cover request, 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 v token, so anything already served refetches the new crop.

Custom focal point setter componentCustom 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 sizes at its 100vw default and the browser over-fetches. The classic next/image trap applies here too: an image in a narrow column still downloads at viewport width. Set sizes to how big the image actually renders, e.g. sizes="(max-width: 768px) 100vw, 33vw".
  • enabled: false means "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 sharp on the Payload config and serverExternalPackages: ['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 v token 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, alt is empty, there's no v token, and the srcset climbs to the 4096 ceiling. Pass the populated doc; you usually have one.
  • extendCollection hands you the upload config. The plugin adds the pipeline to your collection, but mimeTypes, localizeAlt, and maxOriginalSize are ignored there — that collection's upload (and its imageSizes) stays yours to manage.

Exports

ExportFromWhat
imagesPlugin@pro-laico/payload-imagesThe Payload plugin; put imagesPlugin() in your config's plugins.
ResponsiveImage@pro-laico/payload-images/components/imageThe responsive <img> async server component.
getImageUrl@pro-laico/payload-images/utils/urlsBuild one transform URL for an image (an id or a populated doc): OG tags, CSS backgrounds, emails.
buildSrcset@pro-laico/payload-images/utils/urlsBuild a responsive srcset + default src for an image id.
buildVariantUrl@pro-laico/payload-images/utils/urlsBuild a single transform URL for an id at a given width.
deriveVersion@pro-laico/payload-images/utils/urlsCompute the cache-busting v token from a source doc (filename + focal point).
FocalPreview@pro-laico/payload-images/admin/focalPreviewAdmin focal-point picker + ratio-preview field (wired via the import map when focalUI is on).
PurgeVariants@pro-laico/payload-images/admin/purgeVariantsAdmin Purge variants button (wired via the import map when focalUI is on).

On this page