Progressive Web Apps Integration Guide
What is a Progressive Web App?
Manifest + service worker (install → activate → fetch) + a caching strategy per asset.
The service worker is a network proxy on its own thread: it precaches the app shell at install, then on every fetch decides cache-first (versioned JS/CSS), network-first (HTML/API, fall back to /offline), or stale-while-revalidate (avatars, thumbs). On Next.js 15 App Router, generate the manifest with app/manifest.ts and build the worker with @serwist/next (a Workbox fork) so precached assets are fingerprinted. For codeAmani's 2G/3G mobile-first market, offline + installable + a tiny critical bundle is the baseline — this very dashboard already ships all of it.
Six PWA primitives
The web platform pieces that make an app installable, offline-capable, and re-engageable.
██████╗ ██╗ ██╗ █████╗
██╔══██╗██║ ██║██╔══██╗
██████╔╝██║ █╗ ██║███████║
██╔═══╝ ██║███╗██║██╔══██║
██║ ╚███╔███╔╝██║ ██║
╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝Progressive Web Apps Integration Guide
Focus: make a Next.js web app installable and offline-capable — a web app manifest for the install, a service worker for the cache, and the right caching strategy per asset. On a 2G/3G phone in Nairobi this is the difference between a usable app and a spinner. This dashboard is already a PWA — the patterns below are running in this very repo.
Overview
A Progressive Web App is, in MDN's words, "an app that's built using web platform technologies, but that provides a user experience like that of a platform-specific app." One codebase ships to every device like a website, yet it can be installed to the home screen, launched full-screen, and keep working with no network. Three pieces make that happen:
- Web app manifest — a small JSON file that tells the browser the app's name, icons, colors and launch mode. Once it is present (plus HTTPS and a service worker), the browser treats the site as installable and offers "Add to Home Screen".
- Service worker — a script that runs in its own thread, outside any page, and sits "as middleware between your PWA and the servers it interacts with." It intercepts every network request in its scope and decides whether to answer from cache, from the network, or from both.
- A caching strategy — the policy the service worker applies per request: serve fast from cache, or fetch fresh from the network, or do both at once. Choosing the right one per asset type is the whole game.
For codeAmani, this is not a nice-to-have. Our users are mobile-first on Android over 2G/3G, where a cold network round-trip can take seconds and connections drop mid-session. An installable, offline-first app with a tiny critical bundle is a core requirement — and it is exactly why this learning dashboard ships app/manifest.ts, a service worker at public/sw.js, an app/offline/ fallback, and lib/pwa.ts.
Official Documentation
| Source | URL | What it covers |
|---|---|---|
| MDN — Progressive Web Apps | https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps | What a PWA is, installability requirements, manifest members |
| web.dev — Service workers | https://web.dev/learn/pwa/service-workers | Lifecycle (install → activate → fetch), scope, request interception |
| Serwist — Next.js getting started | https://serwist.pages.dev/docs/next/getting-started | @serwist/next install + withSerwistInit + app/sw.ts |
Next.js — manifest.ts | https://nextjs.org/docs/app/api-reference/file-conventions/metadata/manifest | App Router manifest generation via MetadataRoute.Manifest |
| Chrome — Caching strategies | https://developer.chrome.com/docs/workbox/caching-strategies-overview | cache-first / network-first / stale-while-revalidate, when to use each |
1. The web app manifest (installability)
The manifest is what turns a page into an installable app. In Next.js App Router you generate it with a typed app/manifest.ts — Next serves it at /manifest.webmanifest and injects the <link rel="manifest"> for you. This is exactly what the dashboard ships (packages/dashboard/app/manifest.ts):
// app/manifest.ts
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "codeAmani Tech-Stack",
short_name: "Tech-Stack",
description: "Interactive developer learning platform.",
start_url: "/",
scope: "/",
display: "standalone", // full-screen, no browser chrome
orientation: "portrait",
background_color: "#0f1115", // splash screen color
theme_color: "#0f1115", // OS UI / status-bar color
categories: ["education", "developer", "productivity"],
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" },
// A *maskable* icon lets Android crop it to any shape without clipping the logo.
{ src: "/icons/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
// Long-press the installed icon → jump straight to a route.
shortcuts: [
{ name: "Search guides", short_name: "Search", url: "/search" },
{ name: "Browse categories", short_name: "Browse", url: "/browse" },
],
};
}Installability checklist (per MDN): served over HTTPS, a manifest with at minimum name/short_name, start_url, display, and icons (192px + 512px), plus a registered service worker. Meet those and Chrome/Edge fire beforeinstallprompt; iOS Safari requires the manual "Share → Add to Home Screen" flow (handled in components/pwa/install-prompt.tsx).
2. The service worker (lifecycle: install → activate → fetch)
A service worker is registered once from a page, then lives on its own. Its lifecycle has three events:
install— fires once after the browser parses the script. The usual job here is to precache the app shell (cache.addAll([...])).activate— fires when the worker is ready to control clients. Clean up old caches here. By default the worker won't control already-open pages until reload —clientsClaim()/skipWaiting()override that.fetch— fires for every request in scope. "When an app requests a resource covered by the service worker's scope, the service worker intercepts the request and acts as a network proxy, even if the user is offline."
Scope is set by file location: a worker at /sw.js controls the whole origin; one at /app/sw.js only controls /app/…. Only one service worker is allowed per scope.
Register it from a client component (the dashboard's components/pwa/register-sw.tsx):
"use client";
import { useEffect } from "react";
export function RegisterServiceWorker() {
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => {
// Non-fatal — the app still works without offline support.
});
}, []);
return null;
}3. Caching strategies (choose one per asset)
The strategy is the policy the fetch handler applies. Pick per asset type:
| Strategy | Behavior | Reach for it on… |
|---|---|---|
| Cache-first | Serve cache; only hit network on a miss, then cache it. | Hash-versioned static assets — JS, CSS, fonts, images. "A speed boost for immutable assets." |
| Network-first | Try network; fall back to cache when offline. | HTML pages and API calls where freshness matters but offline access is valuable. |
| Stale-while-revalidate | Serve cache immediately, refresh it from the network in the background. | Non-critical, occasionally-updated content — avatars, thumbnails. |
| Cache-only | Only the cache, never the network. | Precached, versioned shell assets. |
| Network-only | Always the network, never the cache. | Truly dynamic markup / non-GET. |
Precaching vs runtime caching
- Precaching happens at
install: a fixed, versioned manifest of shell files (/,/offline, core JS/CSS) is fetched up front so the app opens instantly and works offline on first launch. - Runtime caching happens at
fetch: responses are cached lazily, on demand, as the user navigates — guide pages, thumbnails, API data.
The dashboard's hand-written public/sw.js does both: it precaches the shell (["/", "/search", "/browse", "/more", "/offline"]) on install, uses network-first for navigations (falling back to cache and finally /offline), and cache-first for /_next/static/, icons and .webp/.png assets.
4. The Next.js 15 toolchain — Serwist
Hand-writing sw.js is fine for a small, fixed shell, but it doesn't fingerprint precached assets, so a stale page can stick around after a deploy. The actively-maintained answer for Next.js App Router is Serwist (@serwist/next), a fork of Google's Workbox — it builds the precache manifest at compile time and ships Workbox's caching strategies as defaultCache.
Install:
npm i @serwist/next && npm i -D serwistWrap next.config.mjs with withSerwistInit:
// next.config.mjs
import withSerwistInit from "@serwist/next";
const withSerwist = withSerwistInit({
swSrc: "app/sw.ts", // your worker source
swDest: "public/sw.js", // compiled output (what gets registered)
});
export default withSerwist({
// ...your Next.js config
});Write the worker at app/sw.ts. self.__SW_MANIFEST is the injection point Serwist replaces with the build-time precache manifest:
// app/sw.ts
import { defaultCache } from "@serwist/next/worker";
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist";
import { Serwist } from "serwist";
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
declare const self: ServiceWorkerGlobalScope;
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST, // build-time precache manifest
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache, // Workbox strategies, sensible defaults
});
serwist.addEventListeners();defaultCache already applies the right strategy per asset type (cache-first for static, network-first for pages, stale-while-revalidate for the rest), so you rarely write raw fetch logic. Register the compiled public/sw.js exactly as in section 2.
5. Offline support
Offline is the payoff. With the shell precached and /offline as the navigation fallback, a user who loses signal mid-session still sees the app, not the browser's dinosaur. The dashboard's app/offline/page.tsx is a normal route that gets precached and served when a navigation fails. Add an additionalPrecacheEntries for it if it's not auto-detected:
withSerwistInit({
swSrc: "app/sw.ts",
swDest: "public/sw.js",
additionalPrecacheEntries: [{ url: "/offline", revision: "v1" }],
});Going further: background sync & push
- Background Sync lets you defer a failed POST (e.g. an M-Pesa-adjacent action, or saving progress) until connectivity returns — the browser replays it from a
syncevent. Invaluable on flaky 2G/3G. - Push notifications use the Push API + a
pushevent in the worker plus a user permission grant and a VAPID-signed subscription. Powerful for re-engagement, but ask for the grant contextually, not on first load — a cold permission prompt is the fastest way to get blocked forever.
Both run inside the same service worker you already registered.
codeAmani notes
- This dashboard is the reference implementation. Before reaching for a tutorial, read the repo:
app/manifest.ts,public/sw.js,app/offline/page.tsx,lib/pwa.ts, andcomponents/pwa/(register-sw.tsx,install-prompt.tsx,ios-app-shell.tsx,ios-tab-bar.tsx). It already does precache-shell + network-first-navigation + cache-first-static. - Mobile-first on 2G/3G is the whole point. Offline-capable, installable, and a tiny critical bundle aren't polish — they're the baseline for our market. Cache-first your hash-versioned JS/CSS so repeat visits don't touch the network at all; lazy-load everything below the fold.
- Install UX must respect the platform. Chrome/Edge/Android: capture
beforeinstallprompt, stash it, and surface a contextual "Install" button (seeinstall-prompt.tsx) rather than auto-prompting. iOS Safari has no programmatic prompt — show the "Share → Add to Home Screen" hint instead. Hide both whendisplay-mode: standalone(already installed). - HTTPS is non-negotiable for service workers and installability — which the Vercel/Cloudflare edge gives us for free, including preview URLs.
- Maskable icons matter on Android: ship a
purpose: "maskable"icon so the launcher can crop to any shape without clipping the codeAmani mark. - When the shell grows, graduate to Serwist (
@serwist/next) so precached assets are fingerprinted and a deploy can't serve a stale page. Keep the hand-writtensw.jsonly while the shell stays small and fixed.