Ship Responsive Images in Hugo Without an External Pipeline
Hugo Page Bundles plus built-in Resize/Fit image processing give you responsive srcset images with no external pipeline. Here's the render-hook setup, the caching trick, and the trade-offs.
20 Sept 2026, 12:40 UTC

If your Hugo site's images live in static/ and you hand-write <img> tags pointing at full-resolution files, mobile readers are downloading megabytes they don't need. The usual fix — a Node-based image pipeline, a CDN with on-the-fly resizing, or a preprocessing script — adds dependencies and CI complexity. Hugo already ships the pieces you need: Page Bundles for co-locating images with content, and built-in image processing (Resize, Fit, Fill) callable directly from templates. The thesis of this post: for most content-heavy sites, bundles plus a render hook emitting srcset is the simplest responsive-image setup that actually holds up.
Why Page Bundles change the picture
A Page Bundle is a directory whose index.md sits next to its assets:
content/posts/my-trip/
├── index.md
├── cover.jpg
└── trail-map.pngBecause the images are page resources, templates can reach them through .Resources.GetMatch instead of guessing URLs under static/. That lookup is what makes per-page processing practical: the template knows exactly which image belongs to which page, and Hugo tracks the file's content hash for caching.
One structural detail matters: use index.md (a leaf bundle) for regular posts. _index.md creates a branch bundle (a section page), and resource lookup behaves differently there. If .Resources.GetMatch returns nil on a page you expected to have images, the leaf/branch distinction is the first thing to check.
Processing images at build time
Hugo's image methods run in templates, no external tools required. A minimal example in a single-post template:
{{ with .Resources.GetMatch "cover.jpg" }}
{{ $small := .Resize "600x" }}
{{ $large := .Resize "1200x" }}
<img src="{{ $small.RelPermalink }}"
srcset="{{ $small.RelPermalink }} 600w, {{ $large.RelPermalink }} 1200w"
sizes="(max-width: 600px) 100vw, 1200px"
alt="Cover image">
{{ end }}The browser picks the smallest adequate variant from srcset based on viewport width and sizes. Resize "600x" constrains width and preserves aspect ratio; Fit bounds both dimensions, and Fill crops to an exact box — useful for uniform card thumbnails.
Processed files land in resources/_gen/ and are keyed by content, so unchanged images aren't reprocessed on rebuilds. Two practical consequences:
- Commit
resources/_gen/or cache it in CI. For a site with hundreds of images, this turns minutes of re-encoding into seconds. - Pin your Hugo version in CI. Generated paths and encoding defaults have shifted across versions; a silent upgrade can change output.
A render hook so authors write plain Markdown
Requiring shortcodes in every post is friction. A Markdown render hook applies responsive markup automatically to images referenced from bundle content. Create layouts/_default/_markup/render-image.html:
{{ $img := .Page.Resources.GetMatch .Destination }}
{{ if $img }}
{{ $w480 := $img.Resize "480x" }}
{{ $w960 := $img.Resize "960x" }}
<img src="{{ $w480.RelPermalink }}"
srcset="{{ $w480.RelPermalink }} 480w, {{ $w960.RelPermalink }} 960w"
sizes="(max-width: 960px) 100vw, 960px"
alt="{{ .PlainText }}" loading="lazy">
{{ else }}
<img src="{{ .Destination | safeURL }}" alt="{{ .PlainText }}" loading="lazy">
{{ end }}Now  in index.md resolves to the bundle resource and gets a srcset, while external URLs fall through to a plain tag. Authors never think about it. Note the hook only finds page resources when content is organized as bundles — images in static/ hit the fallback branch.
Trade-offs and when to choose assets/ instead
Build-time processing scales with image count times variants requested. Three widths for 500 images is 1,500 encode operations on a cold build — fine with a warm resources/_gen cache, painful without one. Very large galleries may be better served by a dedicated image CDN.
Format support also varies. JPEG and PNG are safe everywhere; WebP encoding requires a sufficiently recent Hugo, and some operations depend on whether you run the extended build. Check before relying on it:
hugo versionRun this locally and in CI and confirm both the version and the "extended" marker match. Then verify end to end: build a test bundle, confirm variants appear under resources/_gen/images/ and in public/, serve the site, and check the generated HTML's srcset URLs return HTTP 200.
Finally, bundles are the wrong home for images shared across many pages — a logo reused in fifty posts would be duplicated per bundle. Put shared assets in the top-level assets/ directory instead; they're global resources reachable via resources.Get and support the same processing methods.
Closing
The actionable version: move one post's images into a leaf bundle, add the render hook above, pin hugo version in CI, and cache resources/_gen/. You'll get responsive images with zero new dependencies — and a clear escape hatch (assets/ or a CDN) the day the build-time trade-off stops being worth it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.