@pro-laico/images
On-demand image transforms with focal-point cropping and built-in low-res placeholders, plus a favicons collection, a picker field, and an Atomic image block.
@pro-laico/images turns a Payload upload collection into an on-demand image pipeline. An upload stores just the original; every rendered size is generated the first time a page asks for it (resized and cropped to a focal point you set in the admin), then cached so it is only ever built once. It also ships a responsive image component, a built-in low-res placeholder, a separate favicons collection with a reusable picker field, and an image block for Atomic content.
The core pieces (the Images collection, the transform endpoint, <ResponsiveImage>, the focal-point UI, and the placeholder) need only @pro-laico/core, so the package works in any Payload + Next.js app. The atomic-payload template wires it up for you, but you can add it on its own. There's a minimal standalone demo in examples/images-only.
Installation
pnpm add @pro-laico/imagesnpm install @pro-laico/imagesyarn add @pro-laico/imagesInstalled for you: @pro-laico/core.
Provide yourself (peers): payload, next, react, and @payloadcms/ui come with your app; also install sharp (Payload needs it too — pass it to buildConfig) and the @pro-laico/atomic plugin.
Setup
@pro-laico/core comes bundled with this plugin (it's a dependency, not a separate install). Its cached reads reach Payload through a config you register once in your Next.js instrumentation hook (registerPayloadConfig); see @pro-laico/core → Setup to wire it up.
Adding the image pipeline to your own Payload project.
Add the plugin to your Payload config
import { buildConfig } from 'payload'
import sharp from 'sharp'
import { imagesPlugin } from '@pro-laico/images'
const serverURL = process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000'
export default buildConfig({
sharp, // required: the transform endpoint resizes/crops with Sharp
serverURL,
cors: [serverURL],
plugins: [imagesPlugin()],
})This registers the Images collection (you upload here; the original is stored untouched), a hidden generatedImages collection (the variant cache, one row per generated size), and the Favicons collection, all under an Assets group in the admin. It also mounts the on-demand transform endpoint at /api/img/:id and a purge endpoint at /api/img/purge/:id. Pass includeFavicons: false to skip the Favicons collection.
Keep Sharp out of the bundle
Sharp ships a native file, so tell Next not to bundle it (Turbopack and webpack both need this):
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
serverExternalPackages: ['sharp'],
}
export default nextConfigSet your server URL
On a cloud or relative-URL storage adapter the endpoint reads the original by fetching the server's own file route, so it needs to know your origin. Set NEXT_PUBLIC_SERVER_URL to your deployment URL (it falls back to http://localhost:3000 for local dev). See Environment variables.
Generate the admin import map
The focal-point picker and the Purge variants button are admin components, so Payload needs them in its import map:
pnpm payload generate:importmapRe-run it whenever you change which admin components are registered.
Upload an image and set its focal point
Open the Images collection and upload an image (each needs alt text). Drag the focal point onto the subject; the ratio tiles preview exactly how each aspect ratio will crop. Accepted types are PNG, JPEG, WebP, and AVIF.
The atomic-payload template already includes @pro-laico/images: the Images, generatedImages, and Favicons collections, the transform endpoint, the admin focal UI (already in the import map), and the image block registered with the other Atomic blocks. You just upload your assets.
Upload images and favicons
Open the Images collection and upload your images (each needs alt text, and a focal point you drag onto the subject), and the Favicons collection for any favicons.
Pick a favicon
In the Site Metadata global, choose the light and dark favicons for your site. Individual pages can override them in their SEO tab.
Using it in your app
Rendering images with <ResponsiveImage>
<ResponsiveImage> is the front-end surface. It emits a plain <img> whose srcset points at the transform endpoint, so the browser downloads the one size that fits where the image renders. It is not next/image, and it runs in both server and client trees. Import it from the @pro-laico/images/components/image subpath:
import { getPayload } from 'payload'
import config from '@payload-config'
import { ResponsiveImage } from '@pro-laico/images/components/image'
// A server component that loads an image and renders it responsively.
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" />
}Pass the populated document (not just an id) when you can: that fills in the intrinsic width/height (so the layout doesn't shift), the alt fallback, and the cache-busting version token. (The placeholder is derived from the id, so it works either way.) The endpoint reads the focal point from the document and crops server-side, so the component never needs it.
The srcset granularity is a prop on <ResponsiveImage>, set where you render the image (it isn't a plugin option). Pass pixelStep (default 50px) to change the step; raise it to emit fewer widths, so the browser has fewer sizes to choose from and the endpoint generates fewer variants. maxEntries (default 8) caps how many entries before the step coarsens.
// fewer, wider steps for this image
<ResponsiveImage image={image} aspectRatio="16:9" sizes="100vw" pixelStep={100} />Want one default everywhere instead of passing pixelStep each time? Wrap the component once and use the wrapper: const Image = (props: ResponsiveImageProps) => <ResponsiveImage pixelStep={100} {...props} />. Any prop you pass at the call site still wins.
Full-bleed / cover fill
By default <ResponsiveImage> is an aspect-ratio box: its <img> is width:100%; height:auto, so the image drives the box's height. For a full-bleed hero, a carousel slide, or any element that sets its own height, pass fill. The wrapper becomes position:absolute; inset:0; size:100% and the <img> renders object-fit:<fit> with no aspect-ratio, covering the (positioned) parent — while still painting the LQIP placeholder. This replaces the bespoke <img className="absolute inset-0 size-full object-cover"> pattern, which bypasses the placeholder/srcset wiring.
// the parent owns the height; the image covers it
<div className="relative h-screen">
<ResponsiveImage image={image} fill sizes="100vw" />
</div>fill ignores aspectRatio (the image is served at its natural ratio and CSS object-fit covers the box). The parent must establish a positioning context (relative / absolute) and a height.
The favicon field
FaviconField is a ready-made upload field that targets the Favicons collection, so you can add a favicon picker to any global or collection. It's what @pro-laico/site uses; add it to your own config the same way:
import { FaviconField } from '@pro-laico/images'
const SiteMetaData = {
slug: 'siteMetaData',
fields: [FaviconField({ name: 'lightFavicon' }), FaviconField({ name: 'darkFavicon' })],
}Pass a name (and anything else a Payload upload field accepts) to mount more than one picker. The field always targets the favicons collection, so an override can't accidentally re-point it.
The image block
The package ships an Image block (slug ImageChild) that lets editors drop an image into Atomic content. It isn't registered by imagesPlugin; it's one of the default child blocks the Atomic renderer wires up, so in most setups you get it for free. To register it in your own block list, import it from the @pro-laico/images/blocks/imageChild subpath:
import { createImageBlock, Image } from '@pro-laico/images/blocks/imageChild'
// The prebuilt block:
const blocks = [Image]
// …or build your own, prepending/appending fields (e.g. a class-name field):
const customImageBlock = createImageBlock({ prependFields: [], appendFields: [] })The block lets the editor choose the image and per-placement display options (alt, aspect ratio, sizes, quality, fit, priority or lazy loading, and whether to show the placeholder). On the front end it renders through @pro-laico/atomic/children as a <ResponsiveImage>.
The Image block is not re-exported from the package root. Import createImageBlock or Image from the @pro-laico/images/blocks/imageChild subpath.
Caching & revalidation
There are three caches working together, and the plugin keeps them honest:
- The variant cache (
generatedImages). The first request for a size generates it and stores it; later requests stream the stored copy. When you replace the file or move the focal point, the change/delete hooks purge that image's stale variants so they regenerate. The Purge variants button (andPOST /api/img/purge/:id) clears them on demand. - The browser/CDN cache. Transform responses are immutable, and every URL carries a
vtoken derived from the source's filename and focal point. Replacing the file or moving the focal point yields a new URL, so already-cached responses refetch; a metadata-only edit (likealt) leaves them untouched. - The Next.js data cache.
getCachedImage(from@pro-laico/images/cache) reads an upload's URL once per id instead of querying Payload on every render, and the Images collection revalidates the matching tag on save and delete.
See Caching & revalidation for how the tags work.
Security & abuse limits
The transform endpoint is public-facing, so it's bounded on several fronts:
- Access control. Source reads run with the collection's access rules (not
overrideAccess). A source you can't read returns404, and a non-public source is served with aprivatecache header and no shared CDN caching — only public images getimmutable+CDN-Cache-Control. The purge endpoint (POST /api/img/purge/:id) requires a logged-in user who can read that source, so it can't be used to force costly regeneration of images they can't even see. - 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 distinct variants withw=1,2,3,…. Output never upscales past the source. - Bounded work per request.
maxInputPixelscaps how many pixels Sharp will decode (a decompression-bomb guard that also caps memory — ~400 MB at 100 MP), and the transform concurrency gate (maxConcurrency/sharpConcurrency) keeps a cold page that requests many sizes at once from saturating CPU/memory. - SSRF + path traversal on source reads. Local reads are confined to the collection's
staticDir— a resolved path that escapes the directory is rejected. Cloud/relative reads self-fetch the source over HTTP with redirects disabled, a 15 s timeout, and a 64 MB body cap, and refuse loopback / private / link-local hosts (blocking cloud metadata endpoints and internal services) while still allowing your configuredNEXT_PUBLIC_SERVER_URLorigin. - Upload validation. Both collections accept only
image/png,image/jpeg,image/webp, andimage/avif.
There's no per-URL request signing — variant generation is gated by the bounds above rather than a shared secret. If you expose the endpoint to fully untrusted traffic, lower dimensionStep / maxDimension / maxInputPixels and put a rate limiter or CDN in front.
Options
imagesPlugin(options?) accepts:
Prop
Type
All options at their defaults, as a working starting point:
imagesPlugin({
enabled: true,
includeFavicons: true,
// imagesOptions / faviconsOptions / generatedImagesOptions: unset (no overrides merged)
pregenerateSizes: false, // store only the original; every size is on demand
focalUI: true,
previewRatios: ['16:9', '9:16', '1:1', '4:3', '3:2', '21:9'],
transform: {
sourceSlug: 'images',
variantSlug: 'generatedImages',
cdnCacheControl: true,
maxDimension: 4096,
defaultQuality: 75,
qualityRange: [40, 95],
defaultFormat: 'auto',
formats: ['auto', 'avif', 'webp', 'jpeg', 'png'],
preferAvif: false, // auto serves WebP; set true to prefer AVIF in fmt=auto
dimensionStep: 50, // snap w/h to a 50px grid (anti-DoS); <= 1 disables
maxInputPixels: 100_000_000, // ~100MP decode cap (bomb + memory guard)
// maxConcurrency: cpus - 1, // cross-image transform gate
// sharpConcurrency: 1, // per-image libvips threads (0 = CPU cores)
},
})Environment variables
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_SERVER_URL | The origin the transform endpoint uses to read the original when the storage adapter reports a relative or cloud URL. Set it to your deployment URL; it falls back to http://localhost:3000, so generation fails in production if it's unset (surfacing as a 502). |
IMAGES_TRANSFORM_CONCURRENCY | Max concurrent Sharp transforms in the process (overrides the cpus - 1 default). The transform.maxConcurrency option takes precedence. |
IMAGES_SHARP_CONCURRENCY | Per-image libvips thread cap (overrides the default of 1; 0 = CPU-core count). The transform.sharpConcurrency option takes precedence. |
Exports
The plugin
Export
Type
imagesPluginplugin
Parameters
options?:ImagesPluginOptionsSee the Options table above. Everything is optional.Returns
PluginA Payload config plugin you add to buildConfig({ plugins: [...] }).Example
import { buildConfig } from 'payload'import sharp from 'sharp'import { imagesPlugin } from '@pro-laico/images'export default buildConfig({sharp,plugins: [imagesPlugin()],})Location
@pro-laico/imagesImagesPluginOptionstype
Location
@pro-laico/imagesTransformEndpointConfigtype
transform option — source/variant slugs, format + quality constraints, and the concurrency / dimension caps documented in the transform keys above.Location
@pro-laico/imagesThe collections, hooks, and endpoint factories that imagesPlugin wires up are intentionally not exported — the plugin builds and registers them for you, and the options above cover the realistic customizations. Configure via imagesOptions / faviconsOptions / generatedImagesOptions / transform rather than hand-assembling the pieces.
Fields & admin components
Export
Type
FaviconFieldfunction
favicons collection, so an override can't re-target it.Parameters
args?:Partial<UploadField> & { apf?: APFunction[] }Upload-field overrides (name, label, admin, …) merged onto the field; name lets you mount several pickers. apf attaches @pro-laico/core upload hooks.Returns
UploadFieldA Payload upload field pointing at the favicons collection.Example
import type { GlobalConfig } from 'payload'import { FaviconField } from '@pro-laico/images'export const SiteMetaData: GlobalConfig = {slug: 'siteMetaData',fields: [FaviconField({ name: 'lightFavicon' }), FaviconField({ name: 'darkFavicon' })],}Location
@pro-laico/imagesFocalPreviewcomponent
focalUI is on; registered via the import map, so you rarely import it directly.Location
@pro-laico/images/admin/focalPreviewPurgeVariantscomponent
Location
@pro-laico/images/admin/purgeVariantsFrontend components
Export
Type
ResponsiveImagecomponent
<img> (an on-demand srcset with a low-res LQIP placeholder), for server or client trees. Not next/image.Parameters
props:ResponsiveImagePropsAt least image (a populated doc or a bare id). Optional: aspectRatio, fill (cover a height-driven parent), sizes, quality, fit, format, pixelStep, priority, blur, path, and the usual className / style.Returns
ReactElement | nullThe image element, or null for an empty id.Example
import { ResponsiveImage } from '@pro-laico/images/components/image'// inside a server or client component, with a populated image doc<ResponsiveImage image={image} aspectRatio="16:9" sizes="(max-width: 768px) 100vw, 50vw" />Location
@pro-laico/images/components/imagegetImageUrlfunction
Parameters
resource:ImageResourceA bare id (string/number) or a populated image doc.o?:GetImageUrlOptionsFit, quality, format, aspect ratio, width, version, base URL / path. Width falls back to the doc's intrinsic width, else 1280.Returns
string | nullA /api/img/:id?… URL, or null when there is no id.Example
import { getImageUrl } from '@pro-laico/images/components/buildSrcset'const ogUrl = getImageUrl(image, { width: 1200, aspectRatio: '1.91:1', baseUrl: process.env.NEXT_PUBLIC_SERVER_URL })Location
@pro-laico/images/components/buildSrcsetbuildSrcsetfunction
srcset + default src of transform URLs. <ResponsiveImage> uses it; call it directly for hand-rolled markup.Parameters
id:stringThe image document id.opts?:BuildSrcsetOptionsAspect ratio, fit, quality, format, pixelStep, sourceWidth, version, base URL / path.Returns
{ srcset: string; src: string }The srcset string and a default src.Example
import { buildSrcset, deriveVersion } from '@pro-laico/images/components/buildSrcset'const { srcset, src } = buildSrcset(String(image.id), {aspectRatio: '16:9',sourceWidth: image.width,version: deriveVersion(image),})Location
@pro-laico/images/components/buildSrcsetbuildVariantUrlfunction
<img>).Parameters
id:stringThe image document id.width:numberRequested width in px.o?:BuildUrlOptionsFit, quality, format, aspect ratio, version, base URL / path.Returns
stringA /api/img/:id?… URL.Example
import { buildVariantUrl, deriveVersion } from '@pro-laico/images/components/buildSrcset'const ogUrl = buildVariantUrl(String(image.id), 1200, {aspectRatio: '1.91:1',baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,version: deriveVersion(image),})Location
@pro-laico/images/components/buildSrcsetderiveVersionfunction
v token from an image's filename + focal point, so replacing the file or moving the focal point yields a new URL. Returns undefined for a bare id (no identity to version).Parameters
src?:{ filename?, focalX?, focalY? } | nullA populated image doc (or those fields of it).Returns
string | undefinedA short token, or undefined when no identity is available.Example
import { deriveVersion } from '@pro-laico/images/components/buildSrcset'const v = deriveVersion(image) // pass to buildSrcset / buildVariantUrlLocation
@pro-laico/images/components/buildSrcsetThe image block
Export
Type
createImageBlockfunction
ImageChild) block. Pass prependFields / appendFields to weave extra fields (e.g. a @pro-laico/styles class-name field) into its Image tab.Parameters
options?:ImageBlockOptions{ prependFields?, appendFields? } arrays of Payload fields. Omit it for the block with no extra fields.Returns
BlockA Payload block (slug ImageChild).Example
import type { Block } from 'payload'import { createImageBlock } from '@pro-laico/images/blocks/imageChild'import { ClassNameField } from '@pro-laico/styles/fields/className'const imageBlock: Block = createImageBlock({ prependFields: [ClassNameField({ namePrefix: 'image' })] })Location
@pro-laico/images/blocks/imageChildImageblock
createImageBlock() returns by default.Location
@pro-laico/images/blocks/imageChildImageBlockOptionstype
createImageBlock (prependFields / appendFields).Location
@pro-laico/images/blocks/imageChildImageChildcomponent
<ResponsiveImage>. The Atomic children renderer wires it up for the ImageChild slug, so you rarely import it directly.Parameters
props:RenderChild<ImageChild>The block data and pass-through props the Atomic children renderer supplies (block, pt).Returns
Promise<JSX.Element>A <ResponsiveImage> for the chosen image, or a placeholder when none is set.Example
import { ImageChild } from '@pro-laico/images/blocks/imageChild/component'// register it for the ImageChild slug in your Atomic children mapconst childComponents = { ImageChild }Location
@pro-laico/images/blocks/imageChild/componentCache getter
Export
Type
getCachedImagefunction
image tag and revalidated on save/delete, so a page reads it once instead of re-querying Payload.Parameters
tid:string | null | undefinedThe Images document id. A falsy id returns an empty string.version?:string | nullA legacy size name (thumbnail, square, small, medium, large, xlarge, og), mapped to an on-demand URL. Omit it for the original.Returns
Promise<string | undefined>The image URL, or undefined when it can't be resolved.Example
import { getCachedImage } from '@pro-laico/images/cache'const src = await getCachedImage(block.image?.id, block.version)Location
@pro-laico/images/cacheSchema types
Export
Type
Image (type)type
Location
@pro-laico/images/schemaGeneratedImage (type)type
Location
@pro-laico/images/schemaRelated
@pro-laico/fonts
Manage custom fonts in the Payload admin and use them with next/font/local: upload your fonts, pick the active ones, and a build step delivers them to your app.
@pro-laico/tracking
Turn analytics on and off from the Payload admin: flip a switch for PostHog, Google Tag Manager, or Vercel Analytics and the right scripts load on your site.