installkite

Basics guide, booklet 3 of 8

PWA tutorial: turn a static site into a progressive web app

This tutorial builds a progressive web app from a plain three page site, by hand, so every moving part makes sense. At the end, one command replaces all of it.

The kite workshop: a pattern sheet, scissors, a ruler and a kite being assembled on a workbenchThe kite workshop: a pattern sheet, scissors, a ruler and a kite being assembled on a workbench

The starting point

We start from a small bakery site: index.html, menu.html, about.html, a stylesheet and a logo. No build step, no framework. The goal: visitors can install it, it opens instantly, and it keeps working without a network.

acme-bakery/
acme-bakery/
  index.html
  menu.html
  about.html
  style.css
  logo.svg

Step 1: the manifest

Create manifest.webmanifest next to index.html. The id gives the app a stable identity, start_url is the page it opens, scope the pages that belong to it, and display removes the browser interface:

manifest.webmanifest
{
  "id": "./",
  "name": "Acme Bakery",
  "short_name": "Acme",
  "start_url": "./",
  "scope": "./",
  "display": "standalone",
  "theme_color": "#e8590c",
  "background_color": "#ffffff",
  "icons": [
    { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}

Export the icons from your logo. The maskable one needs extra padding: Android crops it to a circle or squircle, and only the center 80% is guaranteed to show.

Step 2: a service worker that caches the site

The worker runs between your pages and the network. On install it downloads the files the app needs; on fetch it decides where each response comes from. Bump VERSION whenever a file changes, or the browser keeps the old cache:

sw.js
const VERSION = 'v1';
const FILES = ['./', 'menu.html', 'about.html', 'style.css', 'offline.html', 'icons/icon-192.png'];

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(VERSION).then((cache) => cache.addAll(FILES)));
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))),
  );
});

self.addEventListener('fetch', (event) => {
  if (event.request.method !== 'GET') return;
  if (event.request.mode === 'navigate') {
    // Pages: network first, then the cache, then the offline page.
    event.respondWith(
      fetch(event.request).catch(async () => (await caches.match(event.request)) || caches.match('offline.html')),
    );
    return;
  }
  // Everything else: cache first.
  event.respondWith(caches.match(event.request).then((hit) => hit || fetch(event.request)));
});

This works, but it has three weaknesses: a slow network makes pages hang instead of falling back, every deploy downloads every file again, and you must remember to bump the version.

Step 3: link and register

index.html (head)
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#e8590c">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<script>
  if ('serviceWorker' in navigator) {
    addEventListener('load', () => navigator.serviceWorker.register('sw.js'));
  }
</script>

Repeat that block in every page. The worker must sit at the root, because it only controls the folder it is served from and the folders below.

Step 4: updates

When sw.js changes, the browser installs the new worker but keeps the old one in charge until every tab of the site is closed. To apply an update sooner, tell the page and let the visitor choose:

update.js
const reg = await navigator.serviceWorker.register('sw.js');
reg.addEventListener('updatefound', () => {
  const next = reg.installing;
  next.addEventListener('statechange', () => {
    if (next.state === 'installed' && navigator.serviceWorker.controller) {
      if (confirm('A new version is available. Refresh?')) next.postMessage('skip');
    }
  });
});
navigator.serviceWorker.addEventListener('controllerchange', () => location.reload());
// sw.js: self.addEventListener('message', (e) => e.data === 'skip' && self.skipWaiting());

Step 5: icons and iOS tags

The manifest above lists three icons, but a polished app needs more. Android launchers pick the size closest to the screen density, so ship the usual ladder from 48 to 512 pixels. iPhone and iPad ignore manifest icons when an apple-touch-icon exists, and they want a 180 pixel PNG with no transparency, because iOS paints transparent pixels black. Browsers still ask for /favicon.ico on their own, so keep one at the root with 16, 32 and 48 pixel images inside. Exporting all of that by hand from a design tool takes a while and is easy to get subtly wrong, for example a maskable icon whose logo touches the edges and gets cut by a circular mask. The icon sizes guide lists every file and explains the safe zone math, and installkite generates the whole set from one SVG or PNG.

Two more tags help iOS: apple-mobile-web-app-title for the label under the icon and apple-mobile-web-app-status-bar-style for the status bar. Add them next to the manifest link in every page.

Step 6: test and deploy

Serve the folder from localhost, open it in Chrome or Edge and look at the Application panel: the manifest should parse with no warnings, the worker should be activated and the cache should hold your files. Tick Offline in the Network panel and reload; the pages you cached still open, others show the offline page. Then deploy to any HTTPS host and install it from the address bar.

What usually goes wrong

Most broken progressive web apps fail in the same few ways. The worker is served from a subfolder such as /js/sw.js, so it cannot control the pages above it. The cache list names a file that no longer exists after a redesign, so cache.addAll rejects and the new worker never installs, silently. The version constant is not bumped, so visitors keep yesterday's styles for days. Or the manifest is linked with an absolute path on a site that lives in a subfolder. Each of these is easy to miss in development, where you reload with the cache disabled, and easy to catch with an audit before you deploy.

Step 7: the same thing, automated

Everything above, plus the parts that are tedious by hand, comes out of one command:

Terminal
cd acme-bakery
npx installkite init --yes
npx installkite doctor

The generated worker fixes the three weaknesses of the hand written one. Pages race the network against a three second timeout and fall back to the cache. Each precached file carries a content hash, so a deploy downloads only the files that changed. And installkite build rewrites the precache list after each site build, so you never bump a version by hand. The runtime adds the install button, the iPhone instructions and the Refresh to update toast.

Questions

What are the minimum requirements for a PWA?

A web app manifest with a name, icons of 192 and 512 pixels, a start URL and a standalone display mode, a service worker with a fetch handler, and HTTPS.

Do I need a framework to build a PWA?

No. A progressive web app is a set of files that any website can ship: a manifest, icons and a service worker. This tutorial uses plain HTML.

How do I test a PWA locally?

Serve the folder from localhost, which browsers treat as secure, for example with npx serve or python -m http.server. The Application panel in Chrome or Edge developer tools shows the manifest, the worker and the caches, and has an Offline switch.

Why does my service worker not update?

Browsers compare the bytes of sw.js. If the file does not change, nothing updates. Put a version or the file hashes in it, which installkite build does after each site build, and serve sw.js with a short cache lifetime.

Suggestions

Nothing matches. Skye looked behind every cloud.