Featured image of post Transforming Your Hugo Blog into a Progressive Web App (PWA)

Transforming Your Hugo Blog into a Progressive Web App (PWA)

Learn how to turn your Hugo blog into a Progressive Web App (PWA) for cross-platform compatibility, offline functionality, and automatic updates.


In this article, I will present the “Progressive Web App” (PWA) concept and its advantages over a classic web application. I will then detail how I have transformed my Hugo blog into a PWA, with the specificities of each platform, to allow you to do the same transformation on your website.


❯  What is a PWA?

Let’s begin by asking what a PWA is. This is a type of application built using web platform technologies (typically HTML, CSS, and Javascript) that proposes an experience for users similar to or close to **the platform-specific app ** (let’s say, like a classical app installed from an app store like the Google Play Store on Android devices or the App Store on iOS devices).

Progressive Web Apps have some key features that are:

  • Cross-platform compatibility. With a unique codebase, Progressive Web Apps, like websites, can be run on multiple platforms (Linux, Android, Windows, i/mac/iPadOS, …; different devices like desktops, tablets, smartphones, …).
  • Capacity to work offline. Thanks to a service worker, which is a key element of a PWA, elements of the PWA are stored in a cache, which allows the app to be used even when there is no connection available.
  • Installable. Web browsers automatically detect that a website is a PWA and propose that you install it like any other application (except for iOS devices, which I will detail at the end of this article and then in a dedicated post). The user has an app-like interface with a splash screen, icons, gestures, and animations like native applications.
  • Trust and safety. PWAs are served over HTTPS. I then guarantee the user a certain security level, data security, and integrity.
  • Discovery. Since this is still a website, your application, and the different pages can be indexed and reached directly from a search engine.
  • Automatic updates. PWAs benefit from the advantages of websites: they are automatically updated, which doesn’t require manual installation from people who installed them, contrary to native applications.
  • Independence of the app stores. These PWAs are installable directly from the website.

To keep it short, to turn your application into a progressive web app, you will need three main elements:

  • A manifest file, that is a JSON file that defines a set of elements (like the name, the path in your website, the set of icons, …)

  • A service worker to add on cache or read from cache pages of the website, allowing the app to be used offline* An icon for the app. Although this is not mandatory, it is essential to consider, particularly identifying your PWA when users will install them.

  • A reference to the manifest and the service worker in the html pages served to the visitors of the website.

Your website also has to respect a few characteristics: it must be responsible (and then responsive to different kinds of devices, laptops, tablets, or smartphones) and served over HTTPS. I will detail the transformation I have applied to my website to turn it into a PWA.


❯  The webmanifest.json

Have a look at the mdn web docs if you want to have details of all the elements that you can set on the manifest. This document is typically stored at the root of the website.

Create a document named webmanifest.json, on the static folder. For the content, below is the content corresponding to my website, that you can also use as a skeleton to define yours:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
{
  "lang": "en",
  // main language of your website ; used by search engines and for accessibility reasons.
  "name": "LaRomierre",
  // displayed below the icon
  "short_name": "website name",
  // displayed below the icon, if there is no enough place to display the "name"
  "description": "The blog LaRomierre",
  // displayed during the installation of the wpa
  "start_url": "/",
  // define on which page we land when we launch the app
  "scope": "/",
  // define which pages are accessible in the context of the wpa; other pages consulted are displayed on the web browser
  "background_color": "#929295",
  // the color used to generate the splash screen
  "theme_color": "#F5F5FA",
  // the color that will be used to display the user interface elements
  "display": "standalone",
  // defines how the application is displayed. There are 4 possible values: fullscreen, standalone, minimal-ui and browser, which is the default value.
  "icons": [
    // icons of the app, displayed when the app is installed, or on the splash screen
    {
      "src": "/icons/pwa-64x64.png",
      //path to the icon
      "type": "image/png",
      // type of image. Allow notably the user agent to filter format it didn't support
      "sizes": "64x64"
      // the size of the picture. Or more accurately, for which sizes this icon should be used
    },
    {
      "src": "/icons/pwa-192x192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "/icons/pwa-512x512.png",
      "type": "image/png",
      "sizes": "512x512"
    },
    {
      "src": "/icons/pwa-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
      // indicates how the icon can be used. Possible values are: monochrome, maskable and any, which is the default value
    }
  ],
  "shortcuts": [
    // shortcuts on the app, accessible via a right click on desktop, or via a force touch on smartphones.
    {
      "name": "Decap CMS",
      // name of the shortcut
      "description": "CMS to add or edit blog posts",
      // either displayed in a contextual menu when a user select the shortcut, or for accessibility concerns
      "url": "/admin/index.html",
      // path to the page displayed when a user clicks on the shortcut
      "icons": [
        // shortcut's icons. Note that the main icon is still used to build the splash screen
        {
          "src": "/icons/decap/pwa-64x64.png",
          "type": "image/png",
          "sizes": "64x64"
        },
        {
          "src": "/icons/decap/pwa-192x192.png",
          "type": "image/png",
          "sizes": "192x192"
        },
        {
          "src": "/icons/decap/pwa-512x512.png",
          "type": "image/png",
          "sizes": "512x512"
        },
        {
          "src": "/icons/decap/pwa-512x512.png",
          "type": "image/png",
          "sizes": "512x512",
          "purpose": "maskable"
        }
      ]
    },
    {
      "name": "Tech posts",
      "description": "The page containing the tech posts",
      "url": "/categories/tech/",
      "icons": [
        {
          "src": "/icons/tech/pwa-64x64.png",
          "type": "image/png",
          "sizes": "64x64"
        },
        {
          "src": "/icons/tech/pwa-192x192.png",
          "type": "image/png",
          "sizes": "192x192"
        },
        {
          "src": "/icons/tech/pwa-512x512.png",
          "type": "image/png",
          "sizes": "512x512"
        },
        {
          "src": "/icons/tech/pwa-512x512.png",
          "type": "image/png",
          "sizes": "512x512",
          "purpose": "maskable"
        }
      ]
    },
    {
      "name": "Management posts",
      "description": "The page containing the management posts",
      "url": "/categories/management/",
      "icons": [
        {
          "src": "/icons/mgmt/pwa-64x64.png",
          "type": "image/png",
          "sizes": "64x64"
        },
        {
          "src": "/icons/mgmt/pwa-192x192.png",
          "type": "image/png",
          "sizes": "192x192"
        },
        {
          "src": "/icons/mgmt/pwa-512x512.png",
          "type": "image/png",
          "sizes": "512x512"
        },
        {
          "src": "/icons/mgmt/pwa-512x512.png",
          "type": "image/png",
          "sizes": "512x512",
          "purpose": "maskable"
        }
      ]
    }
  ]
}

❯  Focus on the elements of the manifest


❯  The icons

The icons you set on the manifest have multiple usages: first and foremost, to display the app when installed. It provides a recognizable identity to your application. It is also used to generate the splash screen of your app, is shown in the dock or the taskbar for desktop devices, on the app commutator(on MacOS, for example, via the shortcut cmd+tab), to display notifications if you added it to your application…

If you take the manifest I shared earlier, you probably noticed that I define multiple icons (let’s ignore the shortcuts so far). Defining multiple icons allows the system to choose the most appropriate icon, depending on the case. Globally, the logic applied is relatively simple: given which dimension the system needs an icon, it will select the icon with the closest dimension that suits its need. If you want to test more precisely how it concretely works and which icon is used for which usage, you can create monochrome icons: a yellow square for an icon in 64x64, a red one for an icon in 128x128, a blue one for an icon maskable in 512x512… made such experiments is from my point of view the best way to understand this.

On an Android smartphone, with no maskable icon defined
On an Android smartphone, with no maskable icon defined
On an Android smartphone, with a maskable icon defined
On an Android smartphone, with a maskable icon defined

Note that online tools exist to help you create such icons. I recommend you maskable.app to understand an icon’s appearance once it has been masked. I also recommend you assets-generator from vite-pwa, that you install locally and which generates icons that you can directly add to your pwa given an image in input.

Concerning the parameter types, it indicates the format of your picture. The possible values are png, svg, and webp. The parameter purpose is to specify the icon’s purpose or in which context this icon is supposed to be used. Possible values are any (which is the default value), meaning that the icon can be used in any context; maskable, which means that the icon can be adjusted or transformed to match different icon styles like an icon with rounded corners; or monochrome: less common, monochrome icons can be used on specific context like in the toolbar, for notifications, as a status or menu icon, …


❯  Display

This parameter influences how the application is displayed once installed, from the most immersive way (full screen without any controls and without showing the system’s taskbar) to the least immersive (in the browser). Let’s have a deeper look at how the application looks with the different display values.


❯  “display”: “fullscreen”
On an Android smartphone, the information message that the app runs in fullscreen
On an Android smartphone, the information message that the app runs in fullscreen
On an Android smartphone, the app displayed in fullscreen
On an Android smartphone, the app displayed in fullscreen

When display = fullscreen, the application takes the page’s integrity. The controls and the system status bar are hidden. At the first launch on Android, a message informing the user that the app runs in full screen is displayed.


❯  “display”: “standalone”
On an Android smartphone, the app displayed in standalone
On an Android smartphone, the app displayed in standalone

When display = standalone, the system status bar is displayed.


❯  “display”: “minimal-ui”
On an Android smartphone, the app displayed in minimal-ui
On an Android smartphone, the app displayed in minimal-ui

If display = minimal-ui, some elements of the browser are displayed. These elements depend on the website considered. For example, on Chrome on Android, a header containing a read-only header is displayed.


❯  “display”: “browser”

Contrary to the three other modes, this one has a more significant impact: the app is displayed on the browser, and it is not installable. Clicking on install the app invites you to create a shortcut (like any other non-WPA website). Furthermore, you conserve the other advantages of a WPA (in particular, the caching of resources to be consulted offline).

On an Android smartphone, with display=browser, install the app invites to create a shortcut
On an Android smartphone, with display=browser, install the app invites to create a shortcut
Second screen of the shortcut creation
Second screen of the shortcut creation
Once installed, a shortcut icon is displayed on the desktop
Once installed, a shortcut icon is displayed on the desktop
Clicking on the icon opens the app on the browser
Clicking on the icon opens the app on the browser

❯  Splash screen

On Android smartphones, a splash screen is displayed at the launch of the app. This screen is built by the system from 2 elements defined on the manifest: the icon and the background color. With the manifest that I have shared before in this article and the right assets, I obtained the following splash screen at the launch of the app:

The splash screen on Android devices

Android will choose in priority an icon with the purpose “maskable”. For the example, I have updated the icon defined here:

1
2
3
4
5
6
7
8
...
{
  "src": "/icons/pwa-512x512.png",
  "sizes": "512x512",
  "type": "image/png",
  "purpose": "maskable"
}
...

by the following one:

My new super icon!
My new super icon!

And I have also updated the background color:

1
2
3
...
"background_color": "#929295",
...

to this color:

A super color to go with my super icon!
A super color to go with my super icon!

As a result, I obtain this new splash screen:

The splash screen on Android devices

❯  Shortcuts

Back in the payload, there is a section named "shortcuts". In this section, you can define a list of “shortcuts” to access specific sections in your app. In my case, I have created three shortcuts: one to access the tech articles of my blog, a second one to access the articles talking about management, and a last one to access the CMS part of my website. I have specified the URL on which you will land if you click on the shortcut and a set of icons (that are displayed for this shortcut) for each shortcut. Once applied, given an installed app (with displayed = either fullscreen, standalone, or minimal-ui), you can see them in doing a force touch on your Android smartphone:

The PWA shortcuts on Android

Note. Contrary to the previous records I made from the Android emulator, this capture comes from an Android device. Surprisingly, shortcut icons are not masked on Android emulators, which gives these icons a quite ugly style: the icon is contained in a white disk (cf screenshot below).

Appearance of the shortcut icons on Android emulator
Appearance of the shortcut icons on Android emulator

❯  Reference the manifest

Now, you have to reference the manifest in your HTML code. To do this, add in the <head> section, typically located in the file baseof.html of the Hugo theme that you are using, like this:

1
2
3
4
5
6
7
...
<head>
    ...
    <link rel="manifest" href="/webmanifest.json">
    ...
</head>
...

Apply the changes on your theme (typically, if you added it via a git submodule, you commit, push changes in your theme, then update changes in your project via a git add themes/).


❯  The service worker

The service worker answers the promise of an offline app. This is typically a javascript file (that I name in my blog service-worker.js) that you will store at the base of your website, which will cache and serve cached files to allow this offline access.

The service worker code I will share here will store when a user reaches the site for the first time a page that is served when he tries to get an uncached page without an internet connection (offline/). Then, every time this user requests a page, the worker checks if this page has already been asked before. If yes, and the cached page is not expired (with an ETA set at two hours), then the cached page is served. Otherwise, the distant page is requested, stored in a cache, and served. Unless there is no connection, the offline page is served in this case.

You can definitely adapt this worker to better suit your needs: for instance, remove the expiration of the cache or change its duration, cache more pages at the beginning, etc.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
const CACHE_NAME = 'the_cache_name'; // put here a cache name for your worker
const CACHE_DURATION = 7200; // implies that the page in cache expires after 7200 seconds (2 hours)

self.addEventListener('install', function (event) { // executed when a user lands on the website
  event.waitUntil(
    caches.open(CACHE_NAME)
    .then(function (cache) {
      return cache.addAll([
        '/offline/', // we add the page located at the path $BASE_URL/offline/ in cache
      ]);
    })
  );
});

self.addEventListener('fetch', function (event) { // executed when a user request a page on the website
  if (event.request.url.startsWith('chrome-extension://') || // excludes chrome extensions from being cached
    event.request.url.startsWith('http://localhost') || // disable cache when developping locally
    event.request.url.startsWith('https://blog.laromierre.com/admin/')) { // disable cache on the CMS section
    return;
  }

  event.respondWith(
    caches.match(event.request)
    .then(function (response) {
      if (response) {
        const headers = response.headers.get('date');
        if (headers) {
          const expirationDate = new Date(headers).getTime() + CACHE_DURATION * 1000;
          const now = new Date().getTime();
          if (now > expirationDate) {
            // if there is a page in cache, that match the requested page, but this page is expired
            // then fetch a new version of the page and return this page
            return fetchAndUpdateCache(event.request);
          }
        }
        // if there is a page in cache, not expired (or without a date header), then return this page
        return response;
      }

      // no such page in cache: fetch, cache and return the requested page
      return fetchAndUpdateCache(event.request);
    })
  );
});

function fetchAndUpdateCache(request) { // fetch the requested page, store it in cache and return it
  return fetch(request)
  .then(function (networkResponse) {
    if (networkResponse && networkResponse.status === 200) {
      const clonedResponse = networkResponse.clone();
      caches.open(CACHE_NAME)
      .then(function (cache) {
        // add the fetched page in cache only if the requested page has been successfully fetched
        // in other words, we don't add the page if we received a 404
        cache.put(request, clonedResponse);
      });
    }
    return networkResponse; // return the page fetched
  })
  .catch(function () {
    // if we didn't succeed to fetch the page (in case of a loss of connection for example)
    // then we serve the offline page
    return caches.match('/offline/');
  });
}

When a user fetches pages on the website, fetched resources are then automatically added to a cache of the local browser. You can check the pages that are currently in the cache with the Google Chrome Dev Tools by doing a right click on your website > Inspect. Then, on the tab Application, and the menu Cache storage, you should see a line named like the value you give to the variable CACHE_NAME (cf the screenshot below, where CACHE_NAME='fff3d992-4162-43ed-a1e6-4074cf97bdea'):

Cached elements displayed via the Google Chrome Dev Tools
Cached elements displayed via the Google Chrome Dev Tools

❯  Reference the service-worker

Below the declaration of your manifest as described on the previous part (typically on the baseof.html of your theme), add the following code:

1
2
3
4
5
6
7
8
9
...
<script>
    if ('serviceWorker' in navigator) {
        window.addEventListener('load', function () {
            navigator.serviceWorker.register('/service-worker.js')
        });
    }
</script>
...

Note. During the development phase, I recommend you add logs to inform on the success of the registration of the worker. You can achieve this by modifying this code as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
...
<script>
    if ('serviceWorker' in navigator) {
        window.addEventListener('load', function () {
            navigator.serviceWorker.register('/service-worker.js').then(function (registration) {
                console.log(`serviceWorker registration successful with the following scope: ${registration.scope}`);
            }, function (err) {
                console.log(`serviceWorker registration has failed: ${err}`);
            });
        });
    }
</script>
...

❯  Test your app with Lighthouse

At this step, you have all the elements that a WPA needs to have. To test that everything is working as expected or to obtain details regarding missing elements, you can use (yes, again) the Chrome Dev Tools: right-click on the website > Inspect. Then, click on the tab Lighthouse.

Make sure that the “Progressive Web App” box is checked.

The Lighthouse section section in the Google Chrome Dev Tools
The Lighthouse section section in the Google Chrome Dev Tools

Then, click on “Analyze page load”. If the analysis didn’t detect a reference to a PWA manifest on the website, it will display the scores corresponding to the other categories you have checked, and a dash "-" for the PWA section. This is, for instance, the case when we launch the analysis on youtube.com:

Lighthouse analysis for youtube.com
Lighthouse analysis for youtube.com

If a manifest is detected (like in running the analysis on my website), it will display a different logo for the PWA section:

Lighthouse analysis for blog.laromierre.com
Lighthouse analysis for blog.laromierre.com

If you click on the section, Lighthouse displays the current state of the PWA, specifying, in particular, whether the app is installable, what configurations are missing, and so on… a good backlog to build the perfect Progressive Web App!

Details concerning the current configuration of the Progressive Web App on Lighthouse
Details concerning the current configuration of the Progressive Web App on Lighthouse

❯  App installation on different browser / devices

In the previous sections, I essentially discussed installing a Progressive Web App on an Android smartphone. I will detail here what this looks like on different devices, browsers, and OSs since there are some more or less important differences depending on the case.


❯  On desktop devices

On desktop devices, the experience is basically the same, whatever the OS you are using. But there are a few subtle differences.


❯  On Google Chrome

This is still the first choice if you are interested in progressive web apps. Because of the DevTools we already discussed previously, and because Chrome naturally invites a user to install the PWA when available on a website (and also because Google has majorly introduced these PWA).

When you land on a website that proposes a Progressive Web App experience, an animation is played at the right of the address bar:

Installing the PWA on chrome

Once clicked on install, the app is added to the list of installed apps on your computer. The behavior is quite similar whatever the operating system used, except concerning the shortcuts:

  • On Windows, a right-click on the app icon displays the shortcuts with their icons as defined in the manifest:
App installed on Windows via chrome, with a right click displaying the shortcuts
App installed on Windows via chrome, with a right click displaying the shortcuts
  • On macOS and Linux, right-clicking on the app icon displays the shortcuts but without their icons:
App installed on macOS via chrome, with a right click displaying the shortcuts
App installed on macOS via chrome, with a right click displaying the shortcuts
App installed on Linux via chrome, with a right click displaying the shortcuts
App installed on Linux via chrome, with a right click displaying the shortcuts

❯  On Edge

Edge, on Windows, has a similar behavior to Chrome. When you land on the website, Edge proposes you install the app. Once installed, a right-click on its icon displays shortcuts defined in the manifest with their associated icons.

App installation on Windows via Edge
App installation on Windows via Edge

❯  On Firefox

On Firefox, this is fairly simple: Progressive Web Apps are simply not accessible. Indeed, Mozilla Firefox developers made the choice of removing the support of the PWA since Firefox 85 (delivered in early 2021), cf this article.

As a result, if you are using Firefox but you also want to use Progressive Web Apps, you will have to use a secondary compatible browser to install them.


❯  On Safari

On Safari on macOS, surprisingly … it works! If you click on File > Add to the dock, Safari opens a model to propose you install the app.

App installation on macOS via Safari
App installation on macOS via Safari

❯  On Arc web Browser

I add this browser to the list, since this is the main Browser I am using. And unfortunately, although it is based on chromium, itis not supporting yet the Progressive Web Apps. Unfortunately, when I am writing this article, this is not possible to install PWAs from Arc (both on Windows and macOS, the two desktop OS on which Arc is currently available).


❯  On an (Android) smartphone or tablet

I won’t go too deep into the details here on how it works on such devices for a quite simple reason: I took over this article the example of an Android device to take illustrations on how the Progressive Web App is working, so I believe I have already quite well covered the topic! Furthermore, I can add two things: there are no differences between smartphones and tablets regarding how such apps are displayed and working, and Firefox does not support such apps, whatever the platform on which it is running.


❯  The particular case of iOS

Installing and using Progressive Web Apps is possible on iOS. But, for many reasons, installing and using ProgressiveWeb Apps on iOS devices is far from being straightforward, and it asks the developer of these apps to pay particular attention to ensure that their apps are effectively working on iOS (and also on iPadOS).

A few weeks ago, Apple even announced the end of the progressive web apps on their OS in the EU before moving back on their decision (see hereand here). Now, these apps are still only partially working on these OS. It still misses essential features like the display of a popup, similar to the one displayed on Android devices, informing the user that this website has a PWA available and inviting them to install it. I recommend you to have a look at this article, regularly updated by his author, to have a detailed list of what is available on iOS and what is not (yet?).

Since this article is already unreasonably long, I won’t detail how to adapt your PWA to make it work with decent quality on iOS devices. But this will be the topic of a future article!


❯  Wrap up

In this article, I have shared what a Progressive Web App is, its user advantages, how to turn your Hugo website into a PWA, and how to verify that your app works fine via Lighthouse. Finally, I have shared a good overview of the availability of these PWA on the different OS and browsers, except for iOS and iPad, which will be detailed in a future article. I hope this will help you to turn your website into a PWA!


❯  References