Image Pipeline: WebP, Lazy Load, and Medium Zoom

Page content

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.

Hugo

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:
cwebp input.png -o static/img/example.webp
cwebp input.jpg -o static/img/example.webp
rm -f input.png input.jpg
  1. Reference from content with a root-absolute static path:
![Alt text](/img/example.webp)

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 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

, 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 ![...](/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

# 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.