# payload-images

URL: /docs/plugins/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](https://www.sanity.io/docs/image-urls)
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`](/docs/plugins/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.

```bash
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`](/docs/plugins/payload-seed).

![Sample images rendered in the frontend by the images-sandbox example repo](/screenshots/images-front-end.png)

## 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**

```ts
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](#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):

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

```bash
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.

**Reference**

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `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. |
| `extendCollection` | `string` |  | 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). |
| `imagesOverrides` | `Partial<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. |
| `generatedImagesOverrides` | `Partial<CollectionConfig>` |  | A Payload CollectionConfig passthrough, merged onto the hidden generated-images (variant cache) collection. |
| `pixelStep` | `number \| number[]` | `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. |
| `transform` | `TransformEndpointConfig \| false` | `{}` | Config for the on-demand transform + purge endpoints. Pass false to register neither. |
| `transform.sourceSlug` | `string` | `'images'` | Slug of the source upload collection to transform. |
| `transform.variantSlug` | `string` | `'generated-images'` | Slug of the collection that caches generated variants. |
| `transform.cdnCacheControl` | `boolean` | `true` | Also emit CDN-Cache-Control / Vercel-CDN-Cache-Control headers (public images only). |
| `transform.maxDimension` | `number` | `4096` | Hard ceiling on either output dimension. |
| `transform.defaultQuality` | `number` | `75` | Encode quality used when a request omits q. |
| `transform.qualityRange` | `[number, number]` | `[40, 95]` | Allowed quality range; requested values clamp into it. |
| `transform.defaultFormat` | `Format` | `'auto'` | Output format when a request omits fmt (auto negotiates from Accept). |
| `transform.formats` | `Format[]` | `['auto','avif','webp','jpeg','png']` | Output formats the endpoint may emit. |
| `transform.preferAvif` | `boolean` | `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). |
| `transform.maxInputPixels` | `number` | `100000000` | Max source pixels Sharp will decode: a decompression-bomb guard that also caps per-transform memory (~100MP ≈ 400MB). |
| `transform.maxConcurrency` | `number` | `cpus - 1` | Max concurrent Sharp transforms in this process (or IMAGES_TRANSFORM_CONCURRENCY). |
| `transform.sharpConcurrency` | `number` | `1` | Per-image libvips thread cap (or IMAGES_SHARP_CONCURRENCY); 0 = CPU cores. Defaults to 1 for serverless safety. |
| `focalUI` | `boolean` | `true` | Render the focal-point picker + ratio-preview field and the Purge variants button. |
| `previewRatios` | `string[]` | `['16:9','9:16','1:1','4:3','3:2','21:9']` | Aspect ratios shown as preview tiles in the focal-point field. |
| `virtualFields` | `boolean` | `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. |
| `localizeAlt` | `boolean` | `false` | Mark the alt field localized (requires Payload localization). Ignored with extendCollection. |
| `mimeTypes` | `string[]` | `['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). |
| `folders` | `boolean` | `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. |
| `maxOriginalSize` | `number` |  | 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. |
| `placeholder` | `false \| { width?, quality?, format?, maxWidth? }` | `{ 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. |

**TypeScript**

```ts
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](#endpoint) and recorded in `generated-images`. Read is **public**; writes are
logged-in-admin only.

**Fields**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `file` | `upload` |  | The original image (the upload). Stored as-is; shown in relationship fields via displayPreview, and the admin list shows a 160px focal-cropped on-demand thumbnail. |
| `alt` | `text` |  | Required. Screen-reader / SEO text; the admin title and list-search field. Localizable. |
| `focalPoint` | `built-in` |  | Payload's native upload.focalPoint, surfaced by the picker below. |
| `focalPreview` | `ui` |  | Focal-point picker + ratio preview. Only with focalUI. |
| `purgeVariants` | `ui` |  | The Purge variants button. Only with focalUI. |
| `variants` | `join` |  | Lists this image's cached generated-images. Only with focalUI. |
| `src` | `virtual` |  | Default-width optimized URL, computed on read and hidden in admin. Only with virtualFields. |
| `srcset` | `virtual` |  | Responsive srcset of optimized URLs. Only with virtualFields. |
| `placeholderURL` | `virtual` |  | Tiny blurred LQIP URL (URL form, for non-React consumers). Only with virtualFields. |
| `thumbnailURL` | `virtual` |  | 160px focal-cropped thumbnail URL. Only with virtualFields. |
| `blurDataURL` | `virtual` |  | On-demand inline LQIP data-URI; null unless the read sets req.context.lqip / X-LQIP. Only with virtualFields. |

**Hooks**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `purgeStaleVariantsAfterChange` | `afterChange` |  | Purges this image's stale cached variants when its file or focal point changes. |
| `purgeVariantsBeforeDelete` | `beforeDelete` |  | Purges all of this image's cached variants before the doc is deleted. |

### `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**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `file` | `upload` |  | The generated variant image (the upload). |
| `source` | `relationship` |  | The images doc this variant derives from. Required, indexed. |
| `cacheKey` | `text` |  | Unique key for this (source, settings, focal) combo. The admin title. |
| `fit` | `text` |  | The resize fit used to generate it. |
| `format` | `text` |  | Output format. |
| `quality` | `number` |  | Encode quality. |
| `focalX` | `number` |  | Focal point X baked into the crop. |
| `focalY` | `number` |  | Focal point Y baked into the crop. |

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

```ts
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](#responsiveimage) are for JS/React; this is for
everyone else: a mobile app, a GraphQL client, a plain `fetch`, an OG/RSS generator:

```jsonc
// 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](#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

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

**Component**

```tsx
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>
    />
  )
}
```

**Rendered HTML**

```html
<!-- 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`](#plugin-options) 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**:

```tsx
<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](https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Responsive_images).

For a full-bleed hero, or any element that sets its own height, pass `fill`:

```tsx
<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

**Reference**

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `image` | `string \| number \| doc` |  | A bare id, or a populated doc (for natural dims, alt, and the cache-bust version). _(required)_ |
| `alt` | `string` |  | Alt text. Falls back to the doc's alt, then empty. |
| `sizes` | `string` | `'100vw'` | The img sizes attribute; set it to how big the image actually renders. |
| `aspectRatio` | `number \| string` |  | Render ratio (16/9 \| '16:9'); derives each srcset height. Falls back to the doc's natural ratio. Ignored with fill. |
| `fill` | `boolean` | `false` | Cover-fill a positioned parent that sets its own height (full-bleed hero, slide) instead of an aspect-ratio box. |
| `quality` | `number` | `75` | Encode quality baked into each URL. |
| `fit` | `Fit` | `'cover'` | Resize fit. cover focal-crops. |
| `format` | `Format` | `'auto'` | Output format (auto negotiates from Accept). |
| `sourceWidth` | `number` |  | Override the source intrinsic width that caps the srcset (else read from a populated doc). |
| `loading` | `'lazy' \| 'eager'` | `'lazy'` | Native <img> loading. Set 'eager' for an above-the-fold hero. |
| `fetchPriority` | `'high' \| 'low' \| 'auto'` | `'auto'` | Native <img> fetchpriority. Set 'high' for the LCP image. |
| `decoding` | `'async' \| 'auto' \| 'sync'` | `'async'` | Native <img> decoding hint. |
| `className` | `string` |  | Applied to the <img>; size / space / round it here. |
| `style` | `CSSProperties` |  | Merged onto the <img>'s style. |
| `baseUrl` | `string` | `'' (same-origin)` | Absolute base for the generated URLs (OG / email contexts). |
| `path` | `string` | `'/api/img'` | Transform endpoint base; set only for a custom API route / Next basePath. |
| `config` | `SanitizedConfig` |  | 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']). |
| `version` | `string` |  | Explicit cache-bust v token; overrides the one derived from the doc. |
| `placeholder` | `boolean \| number \| { width?, quality? }` | `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. |
| `blur` | `boolean` | `true` | Deprecated alias for placeholder (blur={false} to disable). |
| `dataAttributes` | `Record<string, string>` |  | Extra attributes (e.g. data-*) spread onto the rendered img element. |

**TypeScript**

```tsx
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:

```tsx
<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`:

```tsx
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 component](/screenshots/images-back-end.png)

## 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`](/docs/plugins/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', …)`:

```ts
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`](https://github.com/pro-laico/payload-plugins) 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:

```ts
// 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

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