Responsive and optimized images with Hugo

How to take advantage of the amazingly capable image processing built into this SSG.


Note: Please see the Update at the end.

If you use any images on your website, you probably know how important it is to make them fully responsive and as optimized as possible so they provide an optimal user experience, regardless of screen size or connectivity. Fortunately, the Hugo static site generator (SSG) comes with many impressive image processing capabilities which can help you automate this to an amazing degree. Hugo can resize images of all sizes, convert them to multiple different formats, and perform many more image processing feats — all much more quickly than can any other SSG.

Years ago, the availability of Hugo image processing was more restrictive concerning the images’ location within a Hugo project. Specifically, they had to be page resources, and thus in the same folder as the Markdown content calling them. While that’s still perfectly fine, they now also can be global resources, existing in either the project’s assets/ folder or any subfolder thereof.1 I’m old-school and prefer to keep textual content files separate from image files, so I like this flexibility quite a bit.

Update from the future: A few weeks later, I changed to the page-resources approach after receiving some particularly savvy advice.

For truly responsive images, you must define the breakpoints. These are viewport sizes, usually defined in pixels, for the browser to use in deciding which image to serve. Some articles you’ll find out there — as in the references I’ll list at the end — take a more hard-coded approach to the breakpoints than I feel is necessary or appropriate. This probably is because of the sample code from older articles of this type, in which it’s common to assign a variable to each of several breakpoints (e.g., $tiny for a 500-pixel breakpoint, $medium for an 800-pixel one, etc.). Yes, you can do that and it’ll work, but I suggest another method which I’ll describe in a bit.

Still other articles make admittedly effective use of Hugo’s Markdown render hooks to change any standard [Alt text](image.jpg)-style Markdown to responsive/optimized images; but I prefer to take a shortcode approach, for the added control it offers through optional parameters you can specify.

Still, that’s enough griping on my part. It’s time for me to put up or shut up — with this post and the shortcode it suggests for using Hugo to produce responsive, optimized images.

What was I thinking?

Here’s what I built this shortcode to do, based on how I’d used its Cloudinary-using predecessor (more later on this site and Cloudinary):

  • Rather than hard-coding breakpoint sizes into variables and then generating resized images based on those, just loop through a “slice” (array) and pull the breakpoints from them. This makes it easier to adjust the breakpoints when desired. It also produces more elegant code, IMHO.
  • To provide the usual “blur-up” effect with a low-quality image placeholder (LQIP) while the full image loads, generate a tiny LQIP, encode it as Base64, and magnify it enough to serve as the image div’s background. (As you’ll see, just exactly how we do that background styling can depend on other factors — in the case of this site, which host it’s currently using.)
  • Use the picture element to offer choices of WebP and JPG image file formats, giving browsers a choice between two storage-efficient versions of each generated image.2
  • For other optimization, depend on Hugo’s default settings, although it does have quite a few other options.

The code and the comments

Here’s an annotated version of a shortcode I call imgh.html (the h is for Hugo’s native image processing, to distinguish this shortcode from its Cloudinary-using counterpart, imgc.html):

{{- $respSizes := slice "320" "640" "960" "1280" "1600" "1920" -}}
	These are breakpoints, in pixels.
	Adjust these to fit your use cases.
	Obviously, the more breakpoints,
	the more images you'll be producing.
	(Fortunately, Hugo does that
	**really** fast, as you'd expect,
	but watch out for any storage
	issues this can present either
	locally or in your online repo,
	especially if you have a really
	large number of original images.)
{{- $imgBase := "images/" -}}
	This will be from top-level `assets/images`,
	where we'll keep all images for Hugo's
	processing (this makes them "global
	resources," as noted in the documentation).
{{- $src := resources.Get (printf "%s%s" $imgBase (.Get "src")) -}}
{{- $alt := .Get "alt" -}}
{{- $divClass := "" -}}{{/* Init'g */}}
	The styling in $imgClass, below, makes
	an image fill the container horizontally
	and adjust its height automatically
	for that, and then fade in for the LQIP effect.
	Feel free to adjust your CSS/SCSS as desired.
{{- $imgClass := "w-full h-auto animate-fade" -}}
{{- $dataSzes := "(min-width: 1024px) 100vw, 50vw" -}}
	Now we'll create the 20-pixel-wide LQIP
	and turn it into Base64-encoded data, which
	is better for performance and caching.
{{- $LQIP_img := $src.Resize "20x jpg" -}}
{{- $LQIP_b64 := $LQIP_img.Content | base64Encode -}}
	$CFPstyle is for use in styling
	the div's background, as you'll see shortly.
{{- $CFPstyle := printf "%s%s%s" "background: url(data:image/jpeg;base64," $LQIP_b64 "); background-size: cover; background-repeat: no-repeat;" -}}
	Then, we create a 640-pixel-wide JPG
	of the image. This will serve as the
	"fallback" image for that tiny percentage
	of browsers that don't understand the
	HTML `picture` tag.
{{- $actualImg := $src.Resize "640x jpg" -}}
	Now we'll handle the LQIP background for the
	div that will contain the image content; the
	conditional at the top controls whether we're
	doing inline styling --- which is a no-no for
	a tight Content Security Policy (CSP). Here,
	it checks whether we're using nonces (and thus
	a tight CSP), as spec'd in the site config file.
	If so, it creates a new class, named
	with an md5 hash for the value of $src, that
	the div can use to provide the LQIP background.
	Otherwise, it inserts inline styling.
	**THEREFORE** . . .
	If you don't have a problem with inline styling,
	feel free to use only the second option and
	avoid the conditional altogether.
{{- $imgBd5 := md5 $src -}}
{{- if .Site.Params.Nonces -}}
		.imgB-{{ $imgBd5 }} { {{ $CFPstyle | safeCSS }} }
	<div class="relative imgB-{{ $imgBd5 }} bg-center">
{{- else -}}
	<div class="relative bg-center" style="{{ $CFPstyle | safeCSS }}">
{{- end -}}
	Now we'll build the `picture` which modern
	browsers use to decide which image, and
	which format thereof, to show. Remember to
	put `webp` first, since the browser will use
	the first format it **can** use, and WebP files
	usually are smaller. After WebP, the fallback
	is the universally safe JPG format.
			{{- with $respSizes -}}
				{{- range $i, $e := . -}}
					{{- if ge $src.Width . -}}
						{{- if $i }}, {{ end -}}{{- ($src.Resize (printf "%sx%s" . " webp") ).RelPermalink }} {{ . }}w
					{{- end -}}
				{{- end -}}
			{{- end -}}"
			sizes="{{ $dataSzes }}"
			{{- with $respSizes -}}
				{{- range $i, $e := . -}}
					{{- if ge $src.Width . -}}
						{{- if $i }}, {{ end -}}{{- ($src.Resize (printf "%sx%s" . " jpg") ).RelPermalink }} {{ . }}w
					{{- end -}}
				{{- end -}}
			{{- end -}}"
			sizes="{{ $dataSzes }}"
		<img class="{{ $imgClass }}"
			src="{{ $actualImg.RelPermalink }}"
			width="{{ $src.Width }}"
			height="{{ $src.Height }}"
			alt="{{ $alt }}"

Use and results

To invoke imgh in Markdown, use it like so3:

{{< imgh src="my-pet-cat_3264x2448.jpg" alt="Photo of a cat named Shakespeare sitting on a window sill" >}}

In this case, it produces:

Photo of a cat named Shakespeare sitting on a window sill

. . . from the resulting HTML, which shows the automatically created hashed names for the Hugo-generated resized images:

<div class="relative imgB-b5bc32dfa3c277a7b3e602ebef8c83ca bg-center">
		<source type="image/webp" srcset="/posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_320x0_resize_q75_h2_box.webp 320w, /posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_640x0_resize_q75_h2_box.webp 640w, /posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_960x0_resize_q75_h2_box.webp 960w, /posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_1280x0_resize_q75_h2_box.webp 1280w, /posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_1600x0_resize_q75_h2_box.webp 1600w, /posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_1920x0_resize_q75_h2_box.webp 1920w" sizes="(min-width: 1024px) 100vw, 50vw">
		<source type="image/jpeg" srcset="/posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_320x0_resize_q75_box.jpg 320w, /posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_640x0_resize_q75_box.jpg 640w, /posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_960x0_resize_q75_box.jpg 960w, /posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_1280x0_resize_q75_box.jpg 1280w, /posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_1600x0_resize_q75_box.jpg 1600w, /posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_1920x0_resize_q75_box.jpg 1920w" sizes="(min-width: 1024px) 100vw, 50vw">
		<img class="w-full h-auto animate-fade" src="/posts/2022/06/responsive-optimized-images-hugo/my-pet-cat_3264x2448_hu0a98823da7db56e37a2cf4ddae586f7b_3793639_640x0_resize_q75_box.jpg" width="3264" height="2448" alt="Photo of a cat named Shakespeare sitting on a window sill" title="Photo of a cat named Shakespeare sitting on a window sill" loading="lazy">

Making comparisons

Long-time readers of this site will recall that, starting in July, 2020, I began using the Cloudinary free tier to host virtually all of this site’s images, after my development and build processes grew increasingly slower as I added more images to the site repo.

So why am I now backtracking to repo-hosted images?

You can be sure it’s not because I have a problem with Cloudinary, because I’ve been very happy with that experience. Rather, I’ve chosen to “dogfood” this shortcode — not merely because I think that’s only fair but also because I wanted to put Hugo to the test, having long been curious about this particular Hugo power.

Besides, there’s a lot of difference between Then and Now.

When the site first began providing responsive images in late 2019, it was through a webpack plugin working with the Eleventy SSG. As the site’s inventory of images grew, so did its build times. Later, when I stopped using webpack and instead built the Eleventy site with package.json scripting, I came up with some JavaScript that used sharp to process the site’s images. It worked well enough, but the build times grew longer. Only when I went to Cloudinary did I cease having to worry about that.

So do I now have to worry about it again? Nope. Today is a different story. Now, instead of that JavaScript-based mishmash, I’m using the Go-based, all-in-one Hugo — whose built-in image processing, like Hugo itself, is preternaturally fast.4

But everything in web dev claims to be “blazing fast,” so let’s look at some proof.

Yesterday, when I did a local hugo build of the site, including the post you’re reading right now, I got:

									 |  EN
	Pages            |  219
	Paginator pages  |   39
	Non-page files   |    0
	Static files     |   69
	Processed images | 1489
	Aliases          |    1
	Sitemaps         |    1
	Cleaned          |   38

Total in 4134 ms

As you see, it all built in slightly over four seconds. (Some of those pre-Cloudinary builds used to take several minutes, even locally.)

However, to be fair, that was with all the images pre-generated by my earlier testing; so, then, I deleted them, forcing Hugo to regenerate all the images on the next build:

									 |  EN
	Pages            |  219
	Paginator pages  |   39
	Non-page files   |    0
	Static files     |   69
	Processed images | 1489
	Aliases          |    1
	Sitemaps         |    1
	Cleaned          |   37

Total in 69905 ms

In just under seventy seconds, Hugo rebuilt nearly 1,500 image files from scratch — and the 200+-page site itself. Pretty slick.

Note: If you’re similarly starting from scratch with many images, and/or you want to minimize issues on your site’s host the first time you switch to this, set your Hugo config file’s timeout value to longer than the default of thirty seconds. After you get to the point where your builds are more incremental where the images are concerned, thirty seconds will be ’waaay more than enough time, both locally and on the host.

When I first used Hugo in 2018–2019, I knew little or nothing about its ability to do image processing and, even if I had known enough, I was much more reluctant back then to get under the hood with Go-type templating. Moreover, since then, Hugo has added two features, the absence of which I’d have considered show-stoppers:

  • The ability to produce WebP images, added only about a year ago in Hugo 0.83.0. (Of course, unlike with Cloudinary, the format choice is something I have to specify in the code rather than something Cloudinary-generated based on browser capabilities. For now, my selections of WebP and JPG will do.)
  • The aforementioned global resources option, because — again — I like keeping images and text in separate places.

I also like the fact that, unlike my Cloudinary-using imgc shortcode, imgh doesn’t require manual entry of width and height, because Hugo gets them automatically from each image $src as $src.Width and $src.Height, respectively. (As you probably know, modern browsers use width and height to set the correct aspect ratio for images where styling doesn’t otherwise handle it.)

Update, 2022-07-26: In the original version of this post, I used Hugo’s imageConfig function to get this information, only to learn later that it wasn’t necessary (and, in fact, caused an issue or two when I made some other revisions in my own code not related to or included in the sample here) so I decided to drop it in favor of the already-there .Width and .Height. Simpler is better.

Closing observations and suggestions

Here are a few more things to keep in mind about using this shortcode.

The image quality is Hugo’s default of 75%, although this and other settings can be configured. (As the saying goes, “I have left that as an exercise for the user.”) But I’ve been pleasantly surprised with this default, especially in comparison to the Cloudinary images these Hugo-generated images have replaced. While some are a bit larger, the vast majority are either roughly the same size or actually smaller, yet I can see little or no tangible difference.

I suggest that you not gitignore resources/_gen/assets, so your repo will include both the original image files and the Hugo-generated versions. That will save time in both local devs/builds and the online build process on most hosts. Just make sure the image files don’t constitute such a load that you risk exceeding your repo host’s limits, especially if you’re using its free tier.

However . . .

If you’re using either of my Hugo shortcodes for static tweets or the one for static Mastodon toots, you probably should continue to gitignore those. If your .gitignore file reads as follows where the resources stuff is concerned, your repo will version-control the images but not the static tweets/toots, as I recommend:


You see, since the generated images end up in resources/_gen/images, the first item will make sure those are version-controlled, as I’m suggesting, while ignoring other things in resources/_gen/. As for the static tweets/toots, those end up in resources/json/ and thus, will not be version-controlled, as is my additional suggestion.


Even if you don’t use imgh or anything like it, I hope this article has at least contributed to your understanding of Hugo’s image processing prowess. Here are other selected articles about using Hugo for creating responsive images. I’ve listed them in order of their publish dates, oldest first. Note that even some which came after Hugo began allowing processing of images as global resources still referred erroneously to Hugo’s earlier file-placement restrictions.

Update, 2023-03-21: Although this page still uses this shortcode to show the example photo (so you can view the resulting effect and HTML), the site’s other bit-mapped images now appear via a shortcode based on a Hugo feature introduced a few months after this post’s original publication. This method requires fewer auto-generated files yet still provides similar functionality.

  1. Despite searching through Hugo release notes and various Hugo documentation updates, I was unable to determine exactly which version first supported this. All I could do was see that the related documentation itself changed sometime in the second half of 2020 to mention the acceptability of global resources for Hugo’s image processing. ↩︎

  2. Cloudinary provided different formats automatically, based on the browser, through the img element. While that’s pretty slick, the lack thereof isn’t a deal-breaker for me. ↩︎

  3. If you happen upon this site’s repo out of curiosity and check out this post’s Markdown file, you’ll notice that each of these examples’ curly-bracketed boundaries also have wrapping /* and */, respectively. That’s because, otherwise, Hugo sees it as real code, not just a representation of it, and acts accordingly — in this case, once again displaying the image. I found this otherwise undocumented workaround in a 2015 comment on the Hugo Discourse forum. This is similar to how Eleventy, when using Nunjucks templating, requires the use of {% raw %} and {% endraw %} for proper display of code blocks which contain certain combinations of characters. (Full disclosure: this footnote is 99% recycled from last year’s “Go big or Go home?” post, where the same issue came up.) ↩︎

  4. In the interest of a fair comparison, I do concede that, through much of the site’s pre-Cloudinary time, it was using a large hero image on every post. However: (1.) the build times were slow even during periods when I would take down the hero images; (2.) when I was using the hero images pre-Cloudinary, I used only downsampled, smaller versions rather than the full-size originals I could use with Cloudinary. (For the images I use now, I am using full-size originals once again, and Hugo handles them quickly and without complaint — something I never dared to do with my old JavaScript-based process.) In short, this isn’t an apples-vs.-apples matchup. ↩︎