Image Pipeline: WebP, Lazy Load, and Medium Zoom
Images are usually the heaviest part of a technical post. The pipeline on this site is deliberately small: convert assets to WebP, reference them from Markdown, let a Goldmark render hook add lazy-loading attributes, and initialize Medium Zoom for click-to-enlarge.

Authoring: WebP under static/img
- Prefer official product art or free stock (Unsplash, Pixabay) when you need illustrations.
- Convert to WebP with
cwebp:
cwebp input.png -o static/img/example.webp
cwebp input.jpg -o static/img/example.webp
rm -f input.png input.jpg
- Reference from content with a root-absolute static path:

Front matter thumbnails use the same files without the leading slash style Hugo expects in params:
thumbnail: "img/example.webp"
Keep names lowercase and hyphenated. Delete non-WebP originals after conversion so the repo does not accumulate duplicate weight.
Render hook: lazy and zoomable
layouts/_default/_markup/render-image.html replaces the default Markdown image renderer:
<img src="{{ .Destination | safeURL }}"
alt="{{ .Text }}"
{{ with .Title}} title="{{ . }}"{{ end }}
loading="lazy"
decoding="async"
class="zoomable" />
Every content image then gets:
loading="lazy"— defer offscreen downloadsdecoding="async"— decode without blocking the main thread as aggressivelyclass="zoomable"— hook for the zoom library
Hugo figure shortcodes are separate; if you use , give those templates the same attributes if you want identical behavior.
Medium Zoom
layouts/partials/zoomable.html loads a vendored script from static/js/medium-zoom.js and selects zoom targets:
<script src="/js/medium-zoom.js"></script>
<script>
var zoomables = document.querySelectorAll('.zoomable > img, img.zoomable');
zoomables.length && mediumZoom(zoomables, {
margin: 0,
scrollOffset: 40,
container: null,
template: null,
background: 'rgba(0, 0, 0, 0.8)'
}
);
</script>
Include the partial once from baseof (end of body). The selector covers both img.zoomable from the render hook and images nested under a .zoomable wrapper if you add one later.
Thumbnails vs in-body images
| Use | Mechanism |
|---|---|
| List / OG / Twitter / JSON-LD image | thumbnail in front matter |
| Explaining a concept in the article | Markdown  after <!--more--> |
Social cards read the thumbnail through baseof and seo_schema. In-body images are for readers on the page and for the zoom UI. Using the same WebP for both is fine when it fits.
Verify
# Asset exists
ls static/img/example.webp
# Rendered HTML has lazy + zoomable
hugo
grep -n 'loading="lazy"' public/hugo/hugo-image-pipeline-webp-lazy-zoom/index.html | head
grep -n 'medium-zoom' public/hugo/hugo-image-pipeline-webp-lazy-zoom/index.html | head
In the browser: scroll a long post and confirm below-the-fold images load late; click an image and confirm the zoom overlay opens.
Summary
Convert to WebP, store under static/img/, reference with Markdown, teach Goldmark to emit lazy zoomable <img> tags, and init Medium Zoom once in the site shell. That is the whole image pipeline—no media CDN required for a personal DevOps blog.