# Rendering images

URL: /docs/plugins/payload-images/rendering

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](/docs/plugins/payload-images/reference).

## Rendering quickstart

Seed the getter once with your app's Payload handle. Then fetch and render anywhere on the server:

```ts title="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 }))
```

```tsx
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](/docs/plugins/payload-images/image-urls), which the plugin auto-mounts
at `/api/img`.

**Component**

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

**Rendered HTML**

```html
<!-- 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](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` — the `<img>`
absolutely fills its positioned parent instead of acting as an aspect-ratio box:

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

**Reference**

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string \| number` |  | The image doc id — carried by the doc spread; the component itself renders nothing from it. _(required)_ |
| `alt` | `string \| null` |  | Alt text, from the doc spread. Empty/null omits the attribute. |
| `src` | `string \| null` |  | The default-width transform URL, from the doc spread. Missing → the component renders null. |
| `srcset` | `string \| null` |  | The responsive srcset, from the doc spread. Missing → the component renders null. |
| `placeholder` | `string \| 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. |
| `aspectRatio` | `number \| "${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. |
| `sizes` | `string` | `'100vw'` | The img sizes attribute; set it to how big the image actually renders. |
| `fill` | `boolean` | `false` | Cover-fill a positioned parent that sets its own height (full-bleed hero, slide) instead of an aspect-ratio box. |
| `fit` | `Fit` | `'cover'` | CSS object-fit. Pass the same fit the read declared so the CSS matches the crop. |
| `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 (wins over the built-in layout styles). |
| `dataAttributes` | `Record<"data-${string}", string>` |  | Extra data-* attributes spread onto the rendered img element. |

**TypeScript**

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

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

```tsx title="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](/docs/plugins/payload-revalidate), …). See
> [Caching](/docs/plugins/payload-images/caching#revalidation) 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.

```tsx title="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.

```tsx title="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`:

```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 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](/docs/plugins/payload-images/caching#guaranteed-presets):**
> `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](/docs/plugins/payload-images/image-urls#the-virtual-url-fields)). 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](#art-direction-a-different-crop-per-breakpoint) for the case that actually needs it.
