Payload Plugins
Pluginspayload-images

Rendering images

Fetch a render-ready image doc with imageFor and paint it with <ResponsiveImage> — srcset, placeholder, and focal crop included.

For AI / LLMs: View Markdown

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:

src/lib/imageFor.ts
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 | numberrequired

The image doc id — carried by the doc spread; the component itself renders nothing from it.

altstring | null

Alt text, from the doc spread. Empty/null omits the attribute.

srcstring | null

The default-width transform URL, from the doc spread. Missing → the component renders null.

srcsetstring | null

The responsive srcset, from the doc spread. Missing → the component renders null.

placeholderstring | null

The 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}" | null

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

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

classNamestring

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

styleCSSProperties

Merged 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 / formatcontext.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:

src/components/Image.tsx
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.

src/components/PostGrid.tsx
// 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.

src/components/ArtDirectedHero.tsx
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.

On this page