Rendering images
Fetch a render-ready image doc with imageFor and paint it with <ResponsiveImage> — srcset, placeholder, and focal crop included.
One helper fetches a render-ready doc. One passive component paints it.
No client JS, no URL math, no layout shift.
Assumes the plugin is installed and registered — see Reference.
Rendering quickstart
Seed the getter once with your app's Payload handle. Then fetch and render anywhere on the server:
import config from '@payload-config'
import { getPayload } from 'payload'
import { createImageFor } from '@pro-laico/payload-images'
// The getPayload promise goes in as-is — only fetch() awaits it, so no top-level await.
export const imageFor = createImageFor(getPayload({ config }))import { ResponsiveImage } from '@pro-laico/payload-images/components/image'
import { imageFor } from '@/lib/imageFor'
export async function Hero({ id }: { id: string }) {
const img = await imageFor(id).aspectRatio('16:9').quality(80).blur('md').fetch()
if (!img) return null
// sizes = how big it actually renders
return <ResponsiveImage {...img} sizes="(max-width: 768px) 100vw, 50vw" />
}Declare aspectRatio once, on the read. The doc comes back carrying the ratio it was cropped
to, so the spread gives the component its CSS box for free — there's no second value to keep in
sync. Set sizes to how big the image actually renders, not how big the file is.
The doc comes back as { id, alt, aspectRatio, src, srcset, placeholder } — a full responsive srcset and
a focal-cropped placeholder data URI, built for exactly the render you declared. It spreads
straight into the component.
The placeholder is opt-in. It's built only when the read asks for one (.blur(tier) /
context.blur); declared without a tier, it defaults to sm. Skip the call and placeholder
is null — no data-URI weight in the HTML for images that don't need it (thumbnails, icons,
tiny cards).
<ResponsiveImage>
A passive <img>. It paints exactly what the read delivered: the doc's src/srcset, plus
its placeholder as an inline background while pixels load.
It fetches nothing, never touches Payload, and is safe in server and client trees alike. It is
not next/image — no loader, no client runtime, no proxy. The srcset already points at
the transform endpoint, which the plugin auto-mounts
at /api/img.
<ResponsiveImage
{...img} // id, alt, aspectRatio, src, srcset, placeholder — from imageFor / your getter
sizes="(max-width: 768px) 100vw, 50vw"
className="rounded-xl" // Tailwind (or any className) → the <img>
/><!-- one element: your className + layout plumbing + the inline placeholder, all on the <img> -->
<img
class="rounded-xl"
src="/api/img/64a…?w=1280&h=720&fit=cover&q=80&fmt=auto&v=1a2b3c"
srcset="/api/img/64a…?w=50&h=28&fit=cover&q=80&fmt=auto&v=1a2b3c 50w,
… (every pixelStep — 50px by default — up to the source)
/api/img/64a…?w=2400&h=1350&fit=cover&q=80&fmt=auto&v=1a2b3c 2400w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="…"
loading="lazy"
fetchpriority="auto"
decoding="async"
style="display:block; width:100%; height:auto; aspect-ratio:1.7777777777777778; object-fit:cover;
background-image:url(data:image/png;base64,iVBOR…); background-size:cover; background-position:center; background-repeat:no-repeat" />The component returns null when the doc has no src/srcset (a deleted image, or a read whose
explicit select left them out). A missing image renders nothing rather than a broken frame.
Set sizes to how big the image actually renders. The default is 100vw; leave it on an
image in a narrow column and the browser over-fetches (the classic 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 — the <img>
absolutely fills its positioned parent instead of acting as an aspect-ratio box:
<div style={{ position: 'relative', height: '100vh' }}>
<ResponsiveImage {...img} fill sizes="100vw" />
</div>Props
The first six props are the doc's own fields — you rarely write them by hand; spread the fetched doc. The rest are presentation:
idstring | numberrequiredThe image doc id — carried by the doc spread; the component itself renders nothing from it.
altstring | nullAlt text, from the doc spread. Empty/null omits the attribute.
srcstring | nullThe default-width transform URL, from the doc spread. Missing → the component renders null.
srcsetstring | nullThe responsive srcset, from the doc spread. Missing → the component renders null.
placeholderstring | nullThe doc's placeholder — painted as the <img>'s background while it loads when it's a URL-shaped value (data:, http(s):, or root-relative). A raw blurhash string is carried but not painted. The quality/crop were decided by the READ that fetched the doc.
aspectRationumber | "${n}:${n}" | nullCSS aspect-ratio for the box, from the doc spread — the ratio the read declared, else the image's natural one — so you never restate it. Pass it only to override. Ignored with fill, where the positioned parent owns the box.
sizesstringdefault '100vw'The img sizes attribute; set it to how big the image actually renders.
fillbooleandefault falseCover-fill a positioned parent that sets its own height (full-bleed hero, slide) instead of an aspect-ratio box.
fitFitdefault 'cover'CSS object-fit. Pass the same fit the read declared so the CSS matches the crop.
loading'lazy' | 'eager'default 'lazy'Native <img> loading. Set 'eager' for an above-the-fold hero.
fetchPriority'high' | 'low' | 'auto'default 'auto'Native <img> fetchpriority. Set 'high' for the LCP image.
decoding'async' | 'auto' | 'sync'default 'async'Native <img> decoding hint.
classNamestringApplied to the <img>; size / space / round it here.
styleCSSPropertiesMerged onto the <img>'s style (wins over the built-in layout styles).
dataAttributesRecord<"data-${string}", string>Extra data-* attributes spread onto the rendered img element.
import { ResponsiveImage } from '@pro-laico/payload-images/components/image'
// Doc fields spread in; every presentation prop at its default.
<ResponsiveImage
{...img}
sizes="100vw"
fill={false}
fit="cover"
loading="lazy"
fetchPriority="auto"
decoding="async"
// optional, no default:
// className="rounded-xl"
// style={{ borderRadius: 8 }}
// dataAttributes={{ 'data-test': 'hero' }}
/>There is also an ImageProps type for the app-level wrapper shown below: id + the declared
render (image, blur) + every presentation prop, with the fetched doc fields (alt, src,
srcset, placeholder) omitted — the wrapper fetches those itself. aspectRatio stays, as the
declared-render override.
The read contract
Everything above is one findByID that declares its render on the read. The virtual fields
compute src/srcset/placeholder for precisely that declaration, inside the field hooks:
const doc = await payload.findByID({
id,
collection: 'images',
depth: 0,
select: RESPONSIVE_IMAGE_SELECT, // alt + src + aspectRatio + srcset + placeholder
context: { image: { aspectRatio: '16:9', quality: 80 }, blur: { quality: 'md' } },
})imageFor is sugar over this contract. Each chain setter maps onto it: aspectRatio /
quality / fit / format → context.image, blur(tier) → context.blur.quality. Chains are
immutable, so a partially-applied one (imageFor(id).aspectRatio('1:1')) can be shared and
branched.
A whole declared render can also seed the chain in one go — handy for a project-level wrapper:
import { type ImageProps, ResponsiveImage } from '@pro-laico/payload-images/components/image'
import { imageFor } from '@/lib/imageFor'
export async function Image({ id, image, blur, ...rest }: ImageProps) {
const doc = await imageFor(id, { image, blur }).fetch()
return doc ? <ResponsiveImage {...doc} {...rest} /> : null
}fetch() resolves null for an empty source or a missing doc, and reads the source collection
off the plugin config (a renamed collections.images included).
fetch() runs the Local API with its defaults — access is not scoped. Reads that need their
own access control or cache layer write the same findByID themselves and wrap it however they
like ('use cache', payload-revalidate, …). See
Caching for the cached-getter pattern.
Populated relationships carry the same fields for free: a page.heroImage upload field arrives
with src/srcset already computed (the collection's defaultPopulate), so you can spread it
into the component without a second fetch. placeholder rides along as null unless the parent
read declared a blur (context: { blur: { quality: 'md' } } on the page fetch).
A grid of cards
One image is the easy case. A grid is where sizes earns its keep: each card is a fraction of
the viewport, and that fraction changes per breakpoint. Get it wrong and every card downloads a
full-width file.
// 3 columns ≥1024px, 2 columns ≥640px, 1 below — so each card is roughly 33vw / 50vw / 100vw.
const CARD_SIZES = '(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw'
async function Card({ id }: { id: string }) {
const img = await imageFor(id).aspectRatio('4:3').blur('sm').fetch()
if (!img) return null
return (
<li className="overflow-hidden rounded-xl">
<ResponsiveImage {...img} sizes={CARD_SIZES} className="w-full" />
</li>
)
}
export async function PostGrid({ ids }: { ids: string[] }) {
return (
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{ids.map((id) => (
<Card key={id} id={id} />
))}
</ul>
)
}Each card fetches its own doc, so a card is one cache entry and one <img> — spread it and the
4:3 box comes with it. The sizes string is the only arithmetic you do: describe the slot, not
the file. Say 33vw and a 1200px-wide viewport picks a ~400px variant instead of a 1200px one.
sizes mirrors your CSS breakpoints — if you change the grid, change sizes. There's no way for
the plugin to know your layout; that's the one thing the browser needs you to tell it. Cards are
also a good place to skip the placeholder: .blur() costs bytes in the HTML, so drop it on small
tiles and keep it for the hero.
Art direction: a different crop per breakpoint
sizes picks a size of the same crop. Art direction is when the crop itself should change —
a wide 21:9 banner on desktop that becomes a tall 4:5 on a phone, where a letterboxed sliver of a
16:9 would be useless. That needs <picture> with a <source> per breakpoint, which means
building the srcset strings yourself: buildSrcset is the same machinery <ResponsiveImage>
uses, exposed.
import { buildSrcset } from '@pro-laico/payload-images/utils/urls'
import { imageFor } from '@/lib/imageFor'
export async function ArtDirectedHero({ id }: { id: string }) {
// A raw read: buildSrcset wants the doc's own width + version to derive the ladder and the
// cache-busting token, so skip the render-intent sugar here.
const img = await getImageDoc(id) // your findByID
if (!img) return null
const wide = buildSrcset(img, { aspectRatio: '21:9' })
const tall = buildSrcset(img, { aspectRatio: '4:5' })
if (!wide || !tall) return null
return (
<picture>
<source media="(min-width: 768px)" srcSet={wide.srcset} sizes="100vw" />
<source media="(max-width: 767px)" srcSet={tall.srcset} sizes="100vw" />
{/* the fallback for anything that ignores <source> */}
<img src={wide.src} alt={img.alt ?? ''} style={{ display: 'block', width: '100%' }} />
</picture>
)
}Every URL is still focal-cropped and versioned — each ratio is just another variant the endpoint generates and caches on first request. The focal point you set in the admin is what makes the 4:5 crop keep the subject; that's the whole reason a different crop is safe to serve.
This is the hand-rolled path: you own the <img>, so the placeholder, loading, fetchPriority,
and the aspect-ratio box are yours to add. Reach for it only when the crop must change — for one
crop at many sizes, <ResponsiveImage> already does all of it.
OG images & raw URLs
A component can't go everywhere. For a social/OG image, a CSS background-image, or an email
you need a plain URL string — that's getImageUrl (from utils/urls). The classic case is
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 url = getImageUrl(page.heroImage, { width: 1200, aspectRatio: '1200:630' }) // string | null
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), focal-cropped and versioned like any transform URL. Pass an explicit
baseUrl to override, or baseUrl: '' for a relative one.
For the OG image itself, prefer a preset:
getImageUrl(page.heroImage, { preset: 'og' }) → https://<site>/api/img/<id>?preset=og&v=…. A preset is
guaranteed to exist (eagerly pre-generated, cap-exempt) and honors exact 1200×630 — so a cold
social crawler never races generation. A default og template ships out of the box.
For common sizes you don't even need it: every doc read already carries virtual URL fields
(image.src, image.srcset, image.placeholderURL, image.thumbnailURL — see
Image URLs). Reach for getImageUrl only
for a specific size or ratio the presets don't cover, like OG's 1200×630. buildSrcset is the
one for a fully hand-rolled <img> / <picture> — pass it the populated doc and it derives the
width cap and cache-busting token itself. See
Art direction for the case that actually needs it.