Skip to content

WebP and AVIF images on a Hugo website

Hugo is the tool that I use to generate this website statically. It’s extremely fast, configurable and comes with a bunch of useful features. Added in version 0.62 Markdown Render Hooks are revolutionary. This feature allows overriding default HTML markup generated by parsing Markdown links, headings and images.

The HTML <picture> element is convenient to serve multiple image versions for different scenarios. It may specify some alternative version based on the display resolution, screen density, operating system appearance mode, and the most optimised file format based on the browser support. Using modern formats like WebP or even AVIF, we can gain significant performance improvements.

Image of WebP and AVIF specificatoins

Using Hugo image render hook sounds like an excellent feature to take full advantage of modern <picture> tag capabilities. Instead of serving the only canonical file, we can do it’s more performant siblings in WebP and AVIF format. Let’s make it happen!

Override default image HTML markup using Hugo image render hook #

Let’s have a look at the Markdown image example and its parsed HTML equivalent.

![Love of my life](cat.jpg)
<img src="cat.jpg" alt="Love of my life">

It does the job, but we can do a tad better than that in 2021. Let’s have a look at the example of an HTML that we would like to get.

<picture>
  <source srcset="cat.avif" type="image/avif" />
  <source srcset="cat.webp" type="image/webp" />
  <img
    src="cat.jpg"
    alt="Love of my life"
    loading="lazy"
    decoding="async"
    width="800"
    height="600"
  />
</picture>

Image render hook comes to the rescue. It’s a basic HTML file that resides in layouts/_default/_markup/render-image.html. The implementation that works for me looks like this, but feel free to adjust it to your needs. If you struggle, feel free to ping me on Twitter or use the comment section below — I’m more than happy to help, as always.

<picture>
  {{ $isJPG := eq (path.Ext .Destination) ".jpg" }}
  {{ $isPNG := eq (path.Ext .Destination) ".png" }}

  {{ if ($isJPG) -}}
    {{ $avifPath:= replace .Destination ".jpg" ".avif" }}
    {{ $avifPathStatic:= printf "static/%s" $avifPath }}

    {{ if (fileExists $avifPathStatic) -}}
      <source srcset="{{ $avifPath | safeURL }}" type="image/avif" >
    {{- end }}

    {{ $webpPath:= replace .Destination ".jpg" ".webp" }}
    {{ $webpPathStatic:= printf "static/%s" $webpPath }}

    {{ if (fileExists $webpPathStatic) -}}
      <source srcset="{{ $webpPath | safeURL }}" type="image/webp" >
    {{- end }}
  {{- end }}

  {{ if ($isPNG) -}}
    {{ $avifPath:= replace .Destination ".png" ".avif" }}
    {{ $avifPathStatic:= printf "static/%s" $avifPath }}

    {{ if (fileExists $avifPathStatic) -}}
      <source srcset="{{ $avifPath | safeURL }}" type="image/avif" >
    {{- end }}

    {{ $webpPath:= replace .Destination ".png" ".webp" }}
    {{ $webpPathStatic:= printf "static/%s" $webpPath }}

    {{ if (fileExists $webpPathStatic) -}}
      <source srcset="{{ $webpPath | safeURL }}" type="image/webp" >
    {{- end }}
  {{- end }}

  {{ $img := imageConfig (add "/static" (.Destination | safeURL)) }}

  <img
    src="{{ .Destination | safeURL }}"
    alt="{{ .Text }}"
    loading="lazy"
    decoding="async"
    width="{{ $img.Width }}"
    height="{{ $img.Height }}"
  />
</picture>

This solution is pretty safe to use. It assumes that the files are named the same as the file extension is the only difference. For example: cat.jpg, cat.webp and cat.avif. Before injecting a new WebP or AVIF resource to the markup, it first checks if a particular file exists in a static directory.

Generate WebP and AVIF formats #

Some of the modern graphic design tools, like Sketch or Photoshop, fully support WebP format already. These are early days for AVIF format, but there are some GUI options for it as well. There’s even Squoosh web app that does a great job. My preferred set of tooling to generate these modern formats are cwebp and avifenc command-line tools. If you are macOS user, Homebrew is probably the best place where you can get them from.

brew install webp
brew install joedrago/repo/avifenc

And this is an example how to use these CLIs.

cwebp cat.jpg -o cat.webp
avifenc --min 10 --max 30 cat.jpg cat.avif

If you are planing to convert all images in a folder to a desired format, a snippet like this may come in handy.

find ./ -type f -name '*.png' -exec sh -c 'avifenc --min 10 --max 30 $1 "${1%.png}.avif"' _ {} \;
find ./ -type f -name '*.jpg' -exec sh -c 'cwebp $1 -o "${1%.jpg}.webp"' _ {} \;

The result of it #

The whole process of adding a hook and regenerating all images on my website took me about two hours. After all, I managed to serve to my visitors around 50% slimmer images — pretty significant performance gain. Source code to my blog on GitHub is waiting for curious ones to be explored.

Hopefully, you found this article helpful, and you learned a thing or two. If you have any questions, please use the comments section below or ping me on Twitter. For now, stay safe 👋

Comments

  • D
    Dariusz Więckiewicz

    Quite unique solution for a lack of webp/avif support in Hugo and impressive 100 score on page speed even when serving full-size images. Off course, most of your images are below the fold, hence not a big issue. I am using image.resize in render hooks for my images to generate them in different sizes (locally) however with your approach I start questioning myself is that needed. Despite PageSize Insight reporting an opportunity: Properly size images, however not taking point for that. I will seriously need to re-think if is worth to resize images or just move with WebP/AVIF approach as you describe.

    👆 you can use Markdown here

    Your comment is awaiting moderation. Thanks!
    • Pawel Grzybek
      Pawel Grzybek

      For me it works like a charm and save is significant!

      👆 you can use Markdown here

      Your comment is awaiting moderation. Thanks!
  • D
    Dmitry

    Hi there! Because svg is no supported in Hugo imageConfig, I propose you to alter the code as following to make it more universal (as otherwise it fails with an exception when it finds svg):

        <img
            src="{{ .Destination | safeURL }}"
            alt="{{ .Text }}"
            loading="lazy"
            decoding="async"
        {{ if not (strings.HasSuffix .Destination ".svg")}}
        {{ $img := imageConfig (add "/static" (.Destination | safeURL | strings.TrimSuffix "#center")) }}
            width="{{ $img.Width }}"
            height="{{ $img.Height }}"
        {{ end }}
    

    👆 you can use Markdown here

    Your comment is awaiting moderation. Thanks!
  • D
    Dmitry

    Whole thing simplification:

        {{ $avifPath:= replace .Destination ".jpg" ".avif" | replace ".png" ".avif" }}
        {{ $avifPathStatic:= printf "static/%s" $avifPath }}
    
        {{ if (fileExists $avifPathStatic) -}}
        <source srcset="{{ $avifPath | safeURL }}" type="image/avif">
        {{- end }}
    
        {{ $webpPath:= replace .Destination ".jpg" ".webp" | replace ".png" ".webp" }}
        {{ $webpPathStatic:= printf "static/%s" $webpPath }}
    
        {{ if (fileExists $webpPathStatic) -}}
        <source srcset="{{ $webpPath | safeURL }}" type="image/webp">
        {{- end }}
    
        <img
            src="{{ .Destination | safeURL }}"
            alt="{{ .Text }}"
            loading="lazy"
            decoding="async"
        {{ if not (strings.HasSuffix .Destination ".svg")}}
        {{ $img := imageConfig (add "/static" (.Destination | safeURL)) }}
            width="{{ $img.Width }}"
            height="{{ $img.Height }}"
        {{ end }}
        />
    

    👆 you can use Markdown here

    Your comment is awaiting moderation. Thanks!
  • D
    Dmitry

    Sincere apologies for not checking it properly, here is the correct multiple replace code:

        {{ $avifPath:= replace (replace .Destination ".jpg" ".avif") ".png" ".avif" }}
        {{ $webpPath:= replace (replace .Destination ".jpg" ".webp") ".png" ".webp" }}
    

    👆 you can use Markdown here

    Your comment is awaiting moderation. Thanks!
  • J
    Ja0nz
    <picture>
      {{ range $ext := (slice "avif" "webp" "jxl") }}
        {{ $dotext := print "." $ext }}
        {{ $path := replace .Destination ".jpg" $dotext }}
        {{ $path = replace $path ".png" $dotext }}
        {{ $path = printf "static/%s" $path }}
        {{ if fileExists $path }}
          <source srcset="{{ $path | safeURL }}" type="image/{{ $ext }}">
        {{ end }}
      {{ end }}
    
      <img
          src="{{ .Destination | safeURL }}"
          alt="{{ .Text }}"
          loading="lazy"
          decoding="async"
      />
    </picture>
    

    👆 you can use Markdown here

    Your comment is awaiting moderation. Thanks!
  • f
    fundor333

    How do I make this code work for the img in a Page Bundle?

    👆 you can use Markdown here

    Your comment is awaiting moderation. Thanks!
    • Pawel Grzybek
      Pawel Grzybek

      Hi. I am not sure about that, sorry, I have never used page bundles.

      👆 you can use Markdown here

      Your comment is awaiting moderation. Thanks!
      • F
        Fundor333

        I write a post about WebP and AVIF with Page bundles and I link this post as reference/source

        👆 you can use Markdown here

        Your comment is awaiting moderation. Thanks!
        • Pawel Grzybek
          Pawel Grzybek

          Thanks a lot! Great addition to my post!

          👆 you can use Markdown here

          Your comment is awaiting moderation. Thanks!
  • M
    Matthew Morgan

    The code included gives the follow error; error calling imageConfig: image: unknown format.

    👆 you can use Markdown here

    Your comment is awaiting moderation. Thanks!
    • Pawel Grzybek
      Pawel Grzybek

      What is the Hugo version that you use?

      👆 you can use Markdown here

      Your comment is awaiting moderation. Thanks!
  • T
    Tim Torres

    There are a couple initial issues I ran into when using this snippet:

    1. Doesn't work for page bundles, as @Fundor333 mentions above
    2. It will break when using a JPG with a .jpeg extension

    Note I haven't incorporated the other enhancements like SVG support mentioned in the comments.

    We don't need to use imageConfig to access the image width and height, so I swapped this:

    {{ $img := imageConfig (add "/static" (.Destination | safeURL)) }}
    
      <img
        src="{{ .Destination | safeURL }}"
        alt="{{ .Text }}"
        loading="lazy"
        decoding="async"
        width="{{ $img.Width }}"
        height="{{ $img.Height }}"
      />
    

    with this:

    {{- $img := .Page.Resources.GetMatch .Destination -}}
    {{- if and (not $img) .Page.File -}}
        {{ $path := path.Join .Page.File.Dir .Destination }}
        {{- $img = resources.Get $path -}}
    {{- end -}}
    {{- with $img -}}
        <img
            src="{{ $img.RelPermalink | safeURL }}"
            alt="{{ $.Text }}"
            {{ with .Title}} title="{{ . }}"{{ end }}
            loading="lazy"
            decoding="async"
            width="{{ .Width }}"
            height="{{ .Height }}"
        />
    {{- end -}}
    

    And I used replaceRE instead of replace to use regex to find and replace both .jpg and .jpeg. Ex: {{ $avifPath:= replaceRE "(jpg|jpeg)$i" ".avif" .Destination }}

    👆 you can use Markdown here

    Your comment is awaiting moderation. Thanks!
    • Pawel Grzybek
      Pawel Grzybek

      Hi, thanks for your input!

      1. Yeah, you are right. I don't work with page bundles at all. Worth exploring a version that works fine with bundles.
      2. Good tip about using regex to target both JPEG and JPG extensions. Thanks for it!
      3. The width and height attributes are required to avoid content shifts. I would recommend keeping them as they were.

      Thanks for great input Tim!

      👆 you can use Markdown here

      Your comment is awaiting moderation. Thanks!
      • T
        Tim Torres

        I did keep the width and height :) you're right, they're also required for valid HTML. Forgot to mention, my updated snippet starting at line 2 is borrowed from @bep's render hook, I don't know exactly what it does 😄 I also added an optional title attribute to the img tag and wrapped the entire thing in an optional figure + figcaption if a title does exist, based on Sebastian's idea.

        👆 you can use Markdown here

        Your comment is awaiting moderation. Thanks!
  • L
    Livia

    Hey Pawel, thank you a ton for this. I am working with Hugo for fun on a very basic level and usually shy away from touching the more tricky stuff. Your article was super easy to understand and even I managed to make it work :D Thank you!

    👆 you can use Markdown here

    Your comment is awaiting moderation. Thanks!
    • Pawel Grzybek
      Pawel Grzybek

      I am glad that my article helped you out! Enjoy!

      👆 you can use Markdown here

      Your comment is awaiting moderation. Thanks!

What'ya think?

👆 you can use Markdown here

Your comment is awaiting moderation. Thanks!