Optimizing WebP Images in Hugo Without Plugins

Hugo can generate WebP images during the build with nothing but its own image-processing functions — no Node tooling, no third-party modules, no system image libraries. The one real requirement is the extended edition of Hugo, which ships with a WebP encoder compiled in. For a content site that means modern-format images, smaller transfers, and a faster Largest Contentful Paint (LCP) without adding a single dependency to your toolchain. This is the Hugo counterpart to Image Optimization Pipelines in Astro, within Performance Optimization & Core Web Vitals for SSGs, where the image is almost always the LCP element.

Prerequisites

  • hugo-extended installed locally and in CI. Confirm with hugo version — the output must contain +extended. The plain binary cannot encode WebP and will fail with image processing not available.
  • Source images placed where Hugo processes them: either assets/ (global, via resources.Get/resources.GetMatch) or a page bundle in content/ (via .Resources.Get). Files under static/ are copied verbatim and not processed.
  • A way to verify byte sizes after the build — ls -lh public/ or your browser's network panel against a deploy preview.

How Hugo's Image Pipeline Works

Hugo processes images at build time using its built-in Go image libraries — not an external dependency like libvips or Sharp. WebP encoding specifically is part of hugo-extended, so the only system requirement is using that edition; you do not install libwebp or libvips. Each call to a processing method (Resize, Fit, Fill, Crop) produces a new image resource, and Hugo writes the result once and caches it in resources/_gen, keyed on the source bytes plus the options string. Later builds reuse the cached variant, so a clean first build pays the encoding cost and every rebuild is nearly free.

Hugo native image-processing flow to WebP A source JPEG in assets or a page bundle is read with resources.GetMatch, processed by Resize or Fill in hugo-extended, encoded to WebP and cached in resources slash underscore gen, then served inside a picture element with a JPEG fallback. One source in, a cached WebP variant out — no plugins hero.jpg assets/ or bundle 1.4 MB hugo-extended Resize / Fill "800x webp q80" WebP variant ~96 KB cached in resources/_gen <picture> WebP source + JPEG fallback First build encodes; every rebuild reuses the cached variant from resources/_gen.
A 1.4 MB source is read from assets or a page bundle, resized and encoded to a ~96 KB WebP by hugo-extended, cached in resources/_gen, and served inside a picture element with a JPEG fallback.

The Recipe

1. Confirm the extended edition

hugo version
# hugo v0.x.x+extended ... — the +extended suffix is required for WebP

2. Generate WebP in a template

Use a <picture> element with a WebP <source> and an original-format <img> fallback. Resize takes the dimensions and options — format and quality — in one space-separated string:

{{ $img := resources.GetMatch .Params.src }}
{{ $webp := $img.Resize "800x webp q80" }}
{{ $fallback := $img.Resize "800x q80" }}
<picture>
  <source srcset="{{ $webp.RelPermalink }}" type="image/webp">
  <img src="{{ $fallback.RelPermalink }}"
       alt="{{ .Params.alt }}"
       width="{{ $fallback.Width }}" height="{{ $fallback.Height }}"
       loading="lazy" decoding="async">
</picture>

Setting width and height from the processed resource reserves layout space and keeps Cumulative Layout Shift (CLS) at zero. Use loading="lazy" for below-the-fold images — but not for the LCP image, which should be eager (see Pitfalls).

3. Emit a responsive set for the hero

For an above-the-fold hero, generate several widths and let the browser choose, while keeping the WebP/fallback split:

{{ $img := resources.GetMatch .Params.src }}
{{ $small := $img.Resize "480x webp q80" }}
{{ $mid := $img.Resize "800x webp q80" }}
{{ $large := $img.Resize "1200x webp q80" }}
{{ $fallback := $img.Resize "1200x q82" }}
<picture>
  <source
    type="image/webp"
    sizes="(max-width: 800px) 100vw, 1200px"
    srcset="{{ $small.RelPermalink }} 480w, {{ $mid.RelPermalink }} 800w, {{ $large.RelPermalink }} 1200w">
  <img src="{{ $fallback.RelPermalink }}"
       alt="{{ .Params.alt }}"
       width="{{ $fallback.Width }}" height="{{ $fallback.Height }}"
       fetchpriority="high">
</picture>

4. Set global quality defaults

Pin defaults in hugo.toml so output is consistent across templates:

[imaging]
  quality = 80
  resampleFilter = "Lanczos"
  anchor = "smart"

[imaging.exif]
  disableDate = true
  disableLatLong = true

quality accepts 1–100; anchor = "smart" picks a focal point when you crop with Fill; stripping EXIF date and GPS keeps output lean and avoids leaking metadata. This is the native equivalent of the build-time compression covered for Astro in Building an Image CDN Pipeline for Static Sites, which is the route to reach for when you want transforms at the edge instead of at build time.

Measured Impact

On a documentation page whose hero was a 1.4 MB JPEG at 1200px, switching to native Hugo WebP at q80 produced the following, measured with ls -lh public/ for bytes and a throttled mid-tier mobile Lighthouse run for LCP:

Variant (1200px hero)BytesLCP (throttled mobile)
Original JPEG q901.4 MB3.0 s
Hugo JPEG q82220 KB2.0 s
Hugo WebP q8096 KB1.6 s

WebP at q80 is about 56% smaller than Hugo's own re-encoded JPEG and roughly 93% smaller than the original, cutting LCP from 3.0 s to 1.6 s. Build time on the page was unaffected after the first run because the variant is cached in resources/_gen. The same priority-hint and hero-sizing logic that earns the last few hundred milliseconds is covered framework-agnostically in Optimizing LCP on Astro with Priority Hints.

Pitfalls & Rollback

  • resources.GetMatch returns nil: wrong case, the image lives under static/, or it is outside both assets/ and any page bundle. Move it into a processed scope and match the exact filename — Linux filesystems are case-sensitive.
  • image processing not available: you are on the standard hugo binary. Install hugo-extended; you do not need system libwebp or libvips.
  • Lazy-loading the LCP image: loading="lazy" on the hero delays it and worsens LCP. Use eager loading plus fetchpriority="high" for the above-the-fold image.
  • Stale variants after editing a source: Hugo keys the cache on the source bytes. If you replace an image but keep the filename, run hugo --gc or delete resources/_gen to force regeneration.
  • Over-cutting quality: below q60 you start to see banding on gradients. Reach for a smaller width before dropping quality further.
  • Rollback: the change is entirely in templates and hugo.toml. Revert the commit and rebuild — resources/_gen regenerates the old variants on the next build, so there is no external state to undo.

Conclusion

Native WebP in Hugo is three things: run hugo-extended, keep sources in assets/ or a page bundle, and call Resize "…x webp qNN" inside a <picture> with a fallback. No plugins, no Node, no system image libraries — resources/_gen keeps rebuilds fast, and a 1.4 MB hero drops to under 100 KB with LCP nearly halved. The same build-time discipline, expressed in each generator's own tooling, runs through the whole image pipeline guide.

FAQ

Does Hugo need external binaries for WebP?

No system libraries. WebP encoding is compiled into the hugo-extended edition, so the only requirement is using that build of Hugo. You do not need to install libwebp or libvips on the host or in your CI image.

Can I convert existing JPEG and PNG sources to WebP?

Yes, at build time. Reference the source through Resize, Fit, or Fill with the webp format in the options string and Hugo emits a WebP variant. The original file is never modified; the variant is written to the output directory and cached in resources/_gen.

How do I keep legacy-browser support?

Wrap the WebP variant in a <picture> element with a <source> of type image/webp and an original-format <img> fallback. Browsers that support WebP take the source; the rest load the fallback automatically, so no browser is left without an image.

Why does the build say image processing not available?

You are running the standard hugo binary, which has no WebP encoder. Switch to the hugo-extended edition. The error is specifically about the missing encoder, not about a missing system library.

How do I avoid reprocessing every image on each build?

Hugo caches processed variants in resources/_gen, keyed on the source and the processing options. As long as that directory persists between builds, unchanged images are reused. In CI, cache the resources directory so the cache survives across runs.

Static Site Generators in Production