Hugo: Get Perfect Score in Google PageSpeed

Hugo: Get Perfect Score in Google PageSpeed

You will see many Hugo themes boast perfect scores on Google PageSpeed Insights(PSI). To be honest, most of them are not as pretty as others and lack a significant amount of JavaScript(or no JavaScript at all). But, it does not matter anymore. Because you can use any theme(or develop on your own) and get an almost perfect score in PSI if you implement the following steps.

Minify, split and reduce render-blocking CSS

We know that CSS files are render-blocking resources: they must be loaded and processed before the browser renders the page. But there is a way to defer CSS files so that it does not block rendering and will improve our First Contentful Paint(FCP) score.

For this implementation, we need to divide our CSS files into two groups. One is bare minimum to display the structure of the page, and rest of the CSS. Then we can minify both CSS groups using Hugo Pipes, and render bare minimum CSS inline and defer the rest of the files. Here is the code:

{{ $baseCSS := resources.Get "css/base.css" }}
{{ $restCSS := resources.Get "css/restOf.css" }}
{{ $bootstrapCSS := resources.Get "css/bootstrap.css" }}
{{ $overrideCSS := resources.Get "css/override.css" }}
{{ $inlineCSS := $baseCSS | resources.Minify }}
<style type="text/CSS">
{{ $inlineCSS.Content | safeCSS }}
{{ $style := slice $restCSS $bootstrapCSS $overrideCSS | resources.Concat "css/defer.css" | resources.Minify | fingerprint }}
<link rel="stylesheet" href="{{ $style.RelPermalink }}" media="print" onload="'all'; this.onload=null;">

As you can see, we are loading the bare minimum CSS with style tag <style>{{ $CSSMin.Content | safeCSS }}</style> so that the browser starts processing them immediately after page loads, while deferring non-critical CSS for later.

Preload crucial JavaScript code and defer rest

Doing these steps will reduce FCP, Time to Interactive(TTI) and Total Blocking Time(TBT). We can take a similar approach to that of CSS here. First, we are going to split JavaScript codes into two groups, then load the compulsory JS code, and will load the rest asynchronously. But we are going to preload content, which should help reduce render time.

{{ $basejs := resources.Get "js/base.js" }}
{{ $baseJSMin := $basejs | resources.Minify | fingerprint}}
<link rel="preload" href="{{ $baseJSMin.RelPermalink }}" as="script">
{{ $restJS := resources.Get "js/rest.js" }}
{{ $jquery := resources.Get "js/JQuery.js" }}
{{ $js := slice $restJS $jquery $overrideCSS | resources.Concat "js/bundle.js" | resources.Minify | fingerprint }}
<script src="{{$baseJSMin.RelPermalink}}"></script>
<script defer src="{{ $js.RelPermalink }}"></script>

You can see here that $baseJSMin(crucial JavaScript file) is being preloaded in head, and finally being loaded in the script tag.

Responsive images with lazy loading

Hugo provides fantastic support for image processing. Using that, we are going to generate images of multiple sizes(basically resize them), and load them using a JavaScript library.

We will be using Lozad.js for lazy loading images. For that, we need to add it to HTML(I am using CDN here):


Now we need to create a shortcode for rendering images. Let us name it render_image.html:

Hugo Project
├── content
├── layouts
│   └── shortcodes
│      ├── render_image.html

And add the following code inside render_image.html file:

{{ $src := resources.Get (.Get "src") }}
{{ $lqipw := default "20x" }}
{{ $smallw := default "500x" }}
{{ $mediumw := default "600x" }}
{{ $largew := default "720x" }}
{{ $lqip := $src.Resize $lqipw }}
{{ $small := $src.Resize $smallw }}
{{ $medium := $src.Resize $mediumw }}
{{ $large := $src.Resize $largew }}

{{ $img := imageConfig(printf "/static/%s" ( .Get "src"))  }}

<img class="lozad"
    src="{{ $lqip.RelPermalink }}"
    data-src="{{ $lqip.RelPermalink}}" data-srcset='{{ with $small.RelPermalink }}{{.}} 500w{{ end }}{{ if ge $src.Width "600" }},{{ with $medium.RelPermalink }}{{.}} 600w{{ end }}{{ end }}{{ if ge $src.Width "720" }},{{ with $large.RelPermalink }}{{.}} 720w{{ end }}{{ end }}' alt='{{ .Get "title" }}' data-sizes="440w">

Now we need to replace all image tags in markdown (ie ![my title](/content/image.jpg)) with:

{{ render_image src="/content/image.jpg" title="my title" }}

You can notice that we are loading a bare minimum version of image(src="{{ $lqip.RelPermalink }}") initially(before loading the actual image). It will improve Cumulative Layout Shift(CLS) score as contents won’t jump as you scroll down.

Also reducing image size will help us with Largest Contentful Paint(LCP) score and FCP.

I got the idea for this implementation from an article on which provides more detail on how to implement responsive images.

Minify HTML and other assets

Now it is time for us to minify HTML files and rest of the assets. We can use this command:

hugo --minify

This command will generate all the minified files along with HTML and store them in /public folder.

In conclusion

This article is based on tips given in I tried to cover as much as possible for improving scores based on Hugo. But I could not cover things like Caching, improving Speed Index etc, because these are dependent on your setup. If you find any problem, please share via the comment section below. Cheers!!

Last updated: May 22, 2024

← Previous
Progressive Web App in Hugo using GitHub Actions

Implement a Progressive Web App in your Hugo based static site using GitHub Actions.

Next →
Beginner's Guide on Web Scraping using Python

Some key concepts, functions and tools you must know before web scrapping using Python.

Share Your Thoughts
M↓ Markdown