Hugo Image Lazy Loading & WebP Conversion (Pure Front-End Config)
Use Hugo Pipes to serve WebP with lazy loading — no JavaScript, no Cloudinary. Real config for posts with 20+ images.
Tested on: Hugo 0.139.4, Lighthouse 11.4 (Chrome 124) Before: LCP 4.2s, 2.1 MB images per post. After: LCP 1.8s, 340 KB.
Problem
A tutorial post had 22 screenshots in static/images/. Lighthouse screamed about unoptimized images and missing lazy loading. I refused to add Cloudinary or a JS lazy-load library.
Move images to assets/
Only resources under assets/ can pass through Hugo Pipes (Resize, Fit, format conversion).
# Before (wrong for Pipes)
static/images/tutorial/step-01.png
# After (correct)
assets/images/tutorial/step-01.png
Markdown reference stays the same path from site root:

Render hook for Markdown images
Create layouts/_default/_markup/render-image.html:
{{- $src := .Destination -}}
{{- $alt := .Text -}}
{{- $title := .Title -}}
{{- $img := resources.Get (printf "images/%s" (strings.TrimPrefix "/" $src)) -}}
{{- if not $img -}}
{{- $img = resources.Get $src -}}
{{- end -}}
{{- if $img -}}
{{- $webp := $img.Fit "1200x webp q82" -}}
{{- $thumb := $img.Fit "40x webp q20" -}}
<figure class="post-figure">
<img
src="{{ $webp.RelPermalink }}"
alt="{{ $alt }}"
{{ with $title }}title="{{ . }}"{{ end }}
width="{{ $webp.Width }}"
height="{{ $webp.Height }}"
loading="lazy"
decoding="async"
style="background: url('{{ $thumb.RelPermalink }}') center/cover"
>
{{- with $alt }}<figcaption>{{ . }}</figcaption>{{ end -}}
</figure>
{{- else -}}
<img src="{{ $src | safeURL }}" alt="{{ $alt }}" loading="lazy" decoding="async">
{{- end -}}
The tiny LQIP background is optional but killed layout shift on slow 3G.
Hero image: do NOT lazy-load LCP
In layouts/_default/single.html, detect first image or use front matter:
---
title: "My Post"
cover: "images/hero.jpg"
coverLCP: true
---
Partial layouts/partials/cover.html:
{{- with .Params.cover -}}
{{- $img := resources.Get . -}}
{{- if $img -}}
{{- $hero := $img.Fit "1600x webp q85" -}}
<img class="post-cover" src="{{ $hero.RelPermalink }}"
width="{{ $hero.Width }}" height="{{ $hero.Height }}"
alt="{{ $.Title }}"
loading="eager" fetchpriority="high" decoding="async">
{{- end -}}
{{- end -}}
Build errors I hit
error calling Resize: this resource is not an image
Image was still in static/. Moved to assets/.
resource not found in cache: images/tutorial/step-01.png
Path in markdown had a leading slash /images/.... Trim it or normalize in the hook.
WARN found .jpg in content but no resource matched
Filename case mismatch on Linux CI: Step-01.PNG vs step-01.png.
hugo.toml performance flags
[imaging]
quality = 82
resampleFilter = "Lanczos"
[caches]
[caches.images]
dir = ":cacheDir/images"
maxAge = "720h"
Verify
hugo --gc --minify
ls -la public/images/tutorial/ # should see .webp hashes
Lighthouse (incognito, mobile):
npx lighthouse http://localhost:1313/posts/heavy-tutorial/ --only-categories=performance
Check Network tab: images load as image/webp, below-fold requests show (lazy).
Results
| Metric | Before | After |
|---|---|---|
| Lighthouse Performance | 68 | 94 |
| LCP | 4.2s | 1.8s |
| Total image weight | 2.1 MB | 340 KB |
| CLS | 0.12 | 0.01 |
No JavaScript added. Works on Cloudflare Pages with default cache headers.