NNicheStackTutorials
← All tutorials
Hugo

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.

·11 min read·Hugo
Hugo Image Lazy Loading & WebP Conversion (Pure Front-End Config) cover

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:

![Step 1](images/tutorial/step-01.png)

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

MetricBeforeAfter
Lighthouse Performance6894
LCP4.2s1.8s
Total image weight2.1 MB340 KB
CLS0.120.01

No JavaScript added. Works on Cloudflare Pages with default cache headers.

Tags: HugoWebPLazy LoadingPerformance