How to Generate Responsive Images at Build Time

This guide automates Responsive Images with srcset and sizes, part of Image & Media Optimization. Hand-written srcset attributes do not scale: someone has to export five widths in three formats for every image, keep filenames consistent, record dimensions, and update everything when an image changes. In practice teams skip it, and pages ship one large JPEG to every device.

A build-time pipeline removes the manual work. Source images live in the repository (or a CMS export) at high resolution; the build generates a fixed set of widths in AVIF, WebP and a JPEG fallback, records intrinsic dimensions, optionally produces placeholders, and gives templates a helper that emits the right <picture> or <img srcset>. Static-site generators and frameworks often provide this (Astro's <Image>, Nuxt Image's static provider, Eleventy Image, Next.js image optimisation), and a small sharp script can do it for anything else.

Build-time responsive image pipeline Pipeline from source images through resizing and format conversion to hashed outputs and template markup. Build-time responsive image pipeline Source image high-res master Resize 480/800/1200/160 0/2000 Encode AVIF + WebP + JPEG Hash + manifest dims, URLs Template helper srcset markup

Rapid Diagnosis

  • Count image variants in the build output. One file per image means no responsive images.
  • Check templates for srcset. Missing or hand-written srcset on content images indicates no pipeline.
  • Compare natural and rendered sizes. Images much larger than their rendered width times DPR are wasted bytes and decode time.
  • Check build times. Pipelines that re-encode every image on every build slow CI; caching matters.

Root Cause Analysis

1. Manual exports. Variants are produced by hand and inevitably drift or are skipped.

2. No dimension metadata. Templates cannot emit width/height, causing CLS.

3. Too many or too few widths. Generating every 100px wastes build time and storage; generating two widths leaves big gaps.

4. No caching. Encoding AVIF is slow; re-encoding unchanged images on every build makes the pipeline painful and likely to be disabled.

Bytes for a 400px-wide product card image on a DPR 2 phone Bar chart of bytes downloaded for a product card image with no pipeline versus build-generated responsive variants. Bytes for a 400px-wide product card image on a DPR 2 phone Single 2400px JPEG 410KB 800w JPEG from pipeline 92KB 800w WebP 61KB 800w AVIF 39KB

Step-by-Step Resolution

1. Choose a width ladder

Pick widths that cover your layouts at common DPRs with modest gaps — for example 320, 480, 640, 800, 1200, 1600, 2000. More widths mean closer matches but more build time and storage; five to seven is typical.

2. Generate variants with sharp and cache by content hash

javascript
// scripts/images.mjs
import sharp from 'sharp';
import { createHash } from 'node:crypto';
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
const WIDTHS = [480, 800, 1200, 1600, 2000];
export async function processImage(src) {
  const buf = await readFile(src);
  const hash = createHash('sha256').update(buf).digest('hex').slice(0, 10);
  const { width, height } = await sharp(buf).metadata();
  const variants = [];
  for (const w of WIDTHS.filter((w) => w <= width)) {
    for (const [fmt, opts] of [['avif', { quality: 50 }], ['webp', { quality: 75 }], ['jpeg', { quality: 78, mozjpeg: true }]]) {
      const out = `dist/img/${hash}-${w}.${fmt === 'jpeg' ? 'jpg' : fmt}`;
      try { await access(out); } catch { await sharp(buf).resize(w).toFormat(fmt, opts).toFile(out); }
      variants.push({ w, fmt, url: out.replace('dist', '') });
    }
  }
  return { width, height, variants };
}
// trade-off: AVIF encoding is CPU-heavy; the existence check caches by content
// hash across builds. Persist dist/img between CI runs or the cache is useless.

Expected outcome: every source image has right-sized variants in three formats, named by content hash.

3. Emit markup from a template helper

javascript
export function picture({ width, height, variants }, { alt, sizes, priority = false }) {
  const set = (fmt) => variants.filter((v) => v.fmt === fmt).map((v) => `${v.url} ${v.w}w`).join(', ');
  const fallback = variants.filter((v) => v.fmt === 'jpeg').at(-1).url;
  return `<picture>
  <source type="image/avif" srcset="${set('avif')}" sizes="${sizes}">
  <source type="image/webp" srcset="${set('webp')}" sizes="${sizes}">
  <img src="${fallback}" srcset="${set('jpeg')}" sizes="${sizes}" width="${width}" height="${height}"
       alt="${alt}" ${priority ? 'fetchpriority="high"' : 'loading="lazy" decoding="async"'}>
</picture>`;
}
// trade-off: the helper needs an accurate sizes value per usage; a wrong sizes
// makes the browser choose too large or too small a variant.

4. Serve with immutable caching

Hashed filenames can be cached for a year with immutable; changed images get new hashes automatically.

Build-time options by stack Common ways to generate responsive images at build time in different frameworks and tools. Build-time options by stack Stack Tool Notes Astro Image / Picture components built-in, sharp-based Nuxt @nuxt/image (ipx / static) presets, placeholders Eleventy eleventy-img shortcode emits picture Custom / other sharp script + helper full control, own caching Next.js next/image on-demand at runtime or export loader

Verification

Check the build output: each source image has the expected variants. In the browser at several widths and DPRs, confirm the chosen currentSrc is the smallest variant that covers the rendered size. Run Lighthouse's "Properly size images" audit; it should pass. Track total image bytes per page view in RUM before and after.

Worked Example: A Static Marketing Site

A static marketing site committed 340 images exported at 2400px as JPEG and referenced them directly. Pages averaged 4.2MB of images on mobile. A sharp pipeline with a five-width ladder, AVIF/WebP/JPEG output, content-hash caching persisted in CI, and a picture() helper used by all templates reduced average mobile image weight to 680KB. The first full build took 9 minutes; subsequent builds with the cache took 25 seconds. LCP p75 on mobile improved by 700ms.

Build-Time vs Image CDN

Build-time generation suits static and mostly-static sites where images are known at build time: it costs nothing at request time and works with any host. Image CDNs suit dynamic catalogues and user uploads, where generating every variant ahead of time is impractical: they transform on first request and cache the result at the edge. Many teams combine them — build-time variants for site chrome and editorial images, an image CDN for catalogue and user content — with one template helper that hides the difference. The guide on image CDNs and fetchpriority covers the CDN side.

Common Mistakes

  • Upscaling small sources. Generate only widths up to the source width.
  • Missing dimensions in markup. The pipeline should return width and height to the template.
  • No cache in CI. AVIF encoding makes uncached builds slow; teams then disable the pipeline.
  • A fixed sizes everywhere. sizes depends on layout; pass it per usage.

Edge Cases

CMS images. If editors upload images at runtime, a build-time pipeline only covers images present at build; use an image CDN or a webhook-triggered build.

Transparent PNGs. Keep alpha: AVIF and WebP support it; fallback should be PNG, not JPEG.

Animated images. Exclude GIFs and animated sources from the still-image pipeline; convert them to video instead.

Colour profiles. Strip or convert to sRGB to avoid colour shifts and save bytes, unless you serve wide-gamut images deliberately.

FAQ

How many widths should I generate?

Five to seven covering your smallest and largest rendered sizes at DPR 1–3 is typical. Gaps of roughly 30–50% between widths keep wasted bytes small without exploding build time.

Is AVIF worth the build time?

Usually yes for images that matter (heroes, product photos). With caching by content hash, the encode cost is paid once per image, not per build.

Should JPEG fallbacks still be generated?

For broad compatibility and email or social previews, yes, though nearly all browsers now support WebP and most support AVIF. Fallbacks cost storage, not user bytes.

Can the pipeline generate placeholders too?

Yes — dominant colours or tiny LQIP previews can be computed with sharp in the same pass and passed to the template.

How do I handle images in Markdown content?

Use a Markdown plugin or post-processing step that replaces image references with the picture() helper's output, so authors keep writing simple Markdown.

Does Next.js need a build-time pipeline?

next/image optimises on demand at runtime (or via a custom loader to an image CDN). For static exports, use a loader or a build-time tool, since the default optimiser requires a server.

How do I keep the pipeline fast as the image library grows?

Cache outputs by content hash (and persist the cache between CI runs), process images in parallel with a concurrency limit matched to CPU cores, and skip widths larger than the source. With those three, adding images costs only their own encoding time, once.