--- title: Image Pipeline: WebP, Lazy Load, and Medium Zoom url: https://devopstales.github.io/hugo/hugo-image-pipeline-webp-lazy-zoom/ date: 2026-07-22 keywords: Hugo, WebP, cwebp, lazy loading, medium zoom, render hook --- 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. <!--more--> ![Hugo](/img/hugo.webp) ## Authoring: WebP under static/img 1. Prefer official product art or free stock (Unsplash, Pixabay) when you need illustrations. 2. Convert to WebP with `cwebp`: ```bash cwebp input.png -o static/img/example.webp cwebp input.jpg -o static/img/example.webp rm -f input.png input.jpg ``` 3. Reference from content with a root-absolute static path: ```markdown ![Alt text](/img/example.webp) ``` Front matter thumbnails use the same files without the leading slash style Hugo expects in params: ```yaml 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: ```go-html-template <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 downloads - `decoding="async"` — decode without blocking the main thread as aggressively - `class="zoomable"` — hook for the zoom library Hugo figure shortcodes are separate; if you use `{{< figure >}}`, 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: ```html <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 `![...](/img/....webp)` 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 ```bash # 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.