Payload Plugins
Pluginspayload-images

Caching

Every image loads instantly after the first request, refreshes itself when you change it, and stays bounded against abuse.

For AI / LLMs: View Markdown

Every image is rendered once and then served from cache forever. Change the source file or move the focal point and it refreshes itself everywhere — nothing to configure. Two features keep even the first request fast: prewarming and the nearby-quality fallback.

The transform pipeline

The transform endpoint renders a requested variant once with Sharp, streams it, and persists it with Next's after() so the response isn't blocked. Every later request for the same URL serves the stored bytes. A cold miss is never an error — it's just the slowest possible response, a full Sharp render — and every response feeds the render-profile registry that prewarming learns from, so the renders a site actually uses don't stay cold: new images get them pre-generated, and presets are generated eagerly on upload.

URLs are content-addressed: the v token is a hash of the source's filename, filesize, and focal layers (focal point, plus the hotspot size and crop rect when set), so a variant URL is immutable and safe to cache forever — responses ship Cache-Control: public, max-age=31536000, immutable (plus the CDN variants) and an ETag.

Caching & abuse limits

The 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 preset manager panel's purge button and POST /api/img/purge/:id clear them on demand.
  • Browser/CDN cache. Responses are immutable and each URL carries the v token, 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 gates on options.access.manage (default: any logged-in user) and, always, on being able to read that source.
  • Bounded variant space (DoS). Requested dimensions snap to the pixelStep grid (or, for an array pixelStep, to the grid + the ladder's exact widths) 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.
  • Per-image variant cap. Each image stores at most variantLimit cached variants (default 200, a per-image field). Past the cap a new freeform size is served — from a nearby existing variant, or generated correctly but not stored — so a public URL can never accumulate unbounded files/rows on your storage. Presets are exempt. See the cap details below.
  • 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).

The purge trigger and the v token key on the filename and filesize, so a replacement that keeps the same filename (upload.overwriteExistingFiles: true, or Payload's built-in admin crop, which overwrites in place) is still caught as long as the bytes change size. The one true blind spot is a same-filename replacement with an identical byte count — practically only an unchanged re-upload.

Nearby-quality fallback

A cache miss normally means the visitor waits on Sharp. With the fallback (default on), a miss where a nearby variant already exists serves those bytes instantly while the exact variant generates in the background. Nearby means the same crop: same fit and focal point, aspect ratio within 8% drift, at least half the effective request width (the request clamped by the no-upscale fit) — at any quality, in any format the client's Accept negotiation proved it decodes (an AVIF stand-in only for AVIF requests, WebP only for WebP/AVIF). The next request gets the accurate image.

The stand-in can never masquerade as the real thing: it's served with Cache-Control: no-store (CDN headers too, no ETag), so nothing anywhere caches it — browser, CDN, or proxy — and it is never persisted under the exact variant's cache key. The moment the background generation lands, every subsequent request serves the exact bytes with the normal immutable headers. Disable with options: { transform: { fallback: false } }.

A stand-in upgrades on the NEXT load, not live. <ResponsiveImage> is a passive <img>: a page that received a stand-in keeps showing it until the image is requested again (navigation, reload). Because the stand-in was never cached, that next request is guaranteed to hit the exact variant.

The variant cap

The transform endpoint is public: any URL that hits it generates and stores a variant. Left unbounded, an attacker enumerating ?w=…&q=…&fmt=… could accumulate huge numbers of files and DB rows on your storage. The cap fixes that at the door.

Each image carries a variantLimit (default 200, editable per image, project default via imagesPlugin({ options: { variantLimit } })). When a freeform cache miss would exceed the limit, the endpoint serves the request without adding storage:

  • if a nearby variant exists, that stand-in is served (no-store);
  • otherwise the exact size is generated and served correctly, but not persisted (so it stays cacheable by a CDN, just not stored server-side).

Either way the stored-variant count stops growing. A real image rarely approaches 200 (a handful of ratios × the srcset ladder × formats); raise a specific image's variantLimit in the admin if it's genuinely heavily art-directed, and put a CDN in front so repeat traffic never re-generates.

The cap bounds storage — the permanent cost. A concurrency gate bounds CPU (a burst past the gate sheds with 503), and presets guarantee your fixed public variants regardless of the cap.

Guaranteed presets

Some variants must always exist and always be servable — an OG image a crawler hits cold, a fixed social card — independent of the cap or on-demand timing. Presets are those: named, cap-exempt, and eagerly pre-generated on upload. The Presets & variants panel on every image manages the whole surface — preset toggles, queued prewarm renders, the cached-variant list with per-variant purge, and the per-image cap.

The panel opens with a width-axis tick line: every cached render plotted on the 0→maxDimension axis — violet for preset-backed variants, green for generated ones, amber for renders the prewarm plan will generate — over gray hairlines marking every width the endpoint could store, with everything beyond the source width shaded (no upscaling). Renders sharing a stored width stack, and hovering a position lists each render's dimensions, fit, quality, and format:

The Presets & variants panel: the width-axis tick line of cached renders, preset toggles (og, thumbnail), planned prewarm renders, the cached-variant list with per-variant purge, the Purge button, and the variant limit inputThe Presets & variants panel: the width-axis tick line of cached renders, preset toggles (og, thumbnail), planned prewarm renders, the cached-variant list with per-variant purge, the Purge button, and the variant limit input

Define reusable templates in config. Two ship out of the box — og (1200×630 cover jpeg) and thumbnail (160×160 cover webp, backing the admin list thumbnail) — and your entries merge on top:

imagesPlugin({
  options: {
    presetTemplates: {
      og:   { width: 1200, height: 630, fit: 'cover', quality: 80, format: 'jpeg' },
      card: { width: 600, aspectRatio: '4:3', fit: 'cover' },
    },
  },
})

Editors toggle templates onto an image (or add a custom one) in the Presets panel; the same data seeds as plain records:

defineSeed('images', ({ file }) => [
  {
    _key: 'hero',
    _file: file('hero.jpg'),
    alt: 'Hero',
    presets: [{ template: 'og' }, { name: 'wide', width: 1920, aspectRatio: '21:9', quality: 70 }],
  },
])

Serve one by name — the URL is short and self-documenting, and (being a finite set) needs no cap:

import { getImageUrl } from '@pro-laico/payload-images/utils/urls'

const ogUrl = getImageUrl(page.heroImage, { preset: 'og' }) // → https://<site>/api/img/<id>?preset=og&v=…

Presets honor exact dimensions (no snap grid), so an og really is 1200×630. They're generated the moment an image is created, its file replaced, or its focal point moved — so the version a crawler needs is already there. A template is referenced by name (edit the config and every image using it updates); a custom preset carries its own inline spec.

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. Fallback stand-ins follow the same discipline: no-store means they are never cached anywhere, so the accurate image takes over on the very next request.

Image URLs self-bust; your page cache doesn't. A cached page keeps serving the old URL until you revalidate that route — exactly as it would for any Payload content.

With @pro-laico/payload-revalidate installed that's automatic and atomic: fetch the image doc through an id-keyed getter and render from it —

import config from '@payload-config'
import { getPayload } from 'payload'
import { createCacheHelpers } from '@pro-laico/payload-revalidate/cache'

const { findDocByID } = createCacheHelpers(getPayload({ config }))

export async function getImage(id: string | number) {
  'use cache'
  return findDocByID('images', id)
}

An alt or focal-point edit busts images:{id} — exactly that one entry re-materializes (with a fresh v= token, so the binary variants re-derive), everywhere the image is used. The internal generated-images variant cache ships custom: { revalidate: false }, so on-demand variant writes never fire bust events. See it wired end-to-end in examples/revalidate-sandbox.

Without the revalidate plugin, handle it however you already do (a revalidatePath / revalidateTag in an afterChange hook, or a dynamic route).

Options

options.variantLimitnumberdefault 200

Max cached variants stored per image. A per-image field; set the project default with `imagesPlugin({ options: { variantLimit } })`. Presets are exempt.

options.transform.fallbackbooleandefault true

On a cache miss, serve an existing nearby variant instantly (`no-store`) while the exact one generates in the background.

On this page