Progressive Web Apps Integration Guide
Technology: pwa · Category: tooling · Last reviewed: 2026-08-23
Source: https://tech-stack.codeamanilabs.org/guide/pwa
Insight:
A PWA is a normal Next.js app plus three pieces — a
manifest.tsfor install, a service worker for the cache, and a caching strategy per asset. The trade-off is a hand-writtensw.js(simple, but no fingerprinted precache) versus Serwist (a Workbox fork that builds the precache at compile time). The wrinkle in 2026: Next 16 builds with Turbopack by default, so the webpack-based@serwist/nextplugin now has a sibling —@serwist/turbopack— for Turbopack builds. For codeAmani's mobile-first 2G/3G market, installable + offline + a tiny critical bundle is the baseline, not polish — and this dashboard is the live reference implementation.
██████╗ ██╗ ██╗ █████╗
██╔══██╗██║ ██║██╔══██╗
██████╔╝██║ █╗ ██║███████║
██╔═══╝ ██║███╗██║██╔══██║
██║ ╚███╔███╔╝██║ ██║
╚═╝ ╚══╝╚══╝ ╚═╝ ╚═╝
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.
stateDiagram-v2
[*] --> Registered: navigator.serviceWorker.register('/sw.js')
Registered --> Installing: parse script
Installing --> Installed: install event · precache shell
Installed --> Activating: (skipWaiting skips the wait)
Activating --> Activated: activate event · purge old caches · clientsClaim
Activated --> Idle
Idle --> Fetching: fetch event (any in-scope request)
Fetching --> Idle: serve from cache / network
Activated --> [*]: new SW found → cycle repeats
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. |
flowchart TD
A[Request in scope] --> B{Versioned / immutable<br/>JS · CSS · fonts · icons?}
B -- yes --> C[Cache-first]
B -- no --> D{HTML page or API<br/>freshness matters?}
D -- yes --> E[Network-first<br/>fallback to cache → /offline]
D -- no --> F{Occasionally-updated<br/>avatar · thumbnail?}
F -- yes --> G[Stale-while-revalidate]
F -- no --> H[Network-only]
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 Serwist toolchain (Next.js 16 / Turbopack)
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 the Next.js App Router is Serwist (currently v9.5.12; a 10.x preview is in the works), a fork of Google's Workbox — it builds the precache manifest at compile time and ships Workbox's caching strategies as defaultCache. next-pwa (shadowwalker) is unmaintained — do not reach for it on a new project.
Turbopack is the default builder in Next 16 (dev and build). The classic
@serwist/nextpackage is a webpack plugin, so it applies when you build with webpack. For Turbopack builds Serwist now ships a separate package —@serwist/turbopack— with a different wiring (see the Turbopack path below). Pick the one that matches how you build. The dashboard is onnext@^16(React 19).
Webpack path — @serwist/next
Install:
npm i @serwist/next && npm i -D serwist
Wrap 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.
Turbopack path — @serwist/turbopack
If you build with Turbopack (the Next 16 default), reach for @serwist/turbopack instead. The shape differs from the webpack plugin:
npm i -D @serwist/turbopack esbuild serwist
// next.config.mjs
import { withSerwist } from "@serwist/turbopack";
export default withSerwist({
// ...your Next.js config
});
Rather than emitting a static file, it serves the compiled worker through a route handler at app/serwist/[path]/route.ts (built with createSerwistRoute from @serwist/turbopack, where you set swSrc, additionalPrecacheEntries, and useNativeEsbuild). The worker at app/sw.ts imports defaultCache from @serwist/turbopack/worker, and you register it with <SerwistProvider swUrl="…"> from @serwist/turbopack/react in your layout instead of the hand-rolled RegisterServiceWorker. Check the Turbopack getting-started for the current API — this path is newer than the webpack plugin and still moving.
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" }],
});
Next 16 also ships an experimental useOffline hook (with a matching experimental.useOffline config flag) for connectivity-aware UI and automatic retries of failed navigations and Server Action requests — a lighter-weight complement to a full service-worker cache when you only need "detect offline, retry when back." It does not replace precaching; treat it as experimental.
Push notifications (Push API + web-push + Server Actions)
Push is the re-engagement lever, and Next's official PWA guide now spells out the full loop. It works across modern browsers, including iOS 16.4+ for a home-screen-installed PWA (installed, not in the Safari tab).
- Subscribe on the client. Register the worker, then subscribe through
pushManager:
"use client";
import { subscribeUser } from "./actions";
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true, // required on Chrome
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, // public VAPID key
),
});
await subscribeUser(JSON.parse(JSON.stringify(sub))); // persist server-side
}
- Send from the server with the
web-pushlibrary inside a Server Action (app/actions.ts) — never from the client, so the private VAPID key stays server-side:
"use server";
import webpush from "web-push";
webpush.setVapidDetails(
"mailto:you@example.com",
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!, // server-only secret
);
export async function sendNotification(sub: PushSubscription, message: string) {
await webpush.sendNotification(
sub,
JSON.stringify({ title: "codeAmani", body: message, icon: "/icon.png" }),
);
}
- Handle it in the worker —
pushshows the notification,notificationclickfocuses the app:
self.addEventListener("push", (event) => {
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title, { body: data.body, icon: data.icon }),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow("/"));
});
Generate the VAPID pair once with npx web-push generate-vapid-keys, then set NEXT_PUBLIC_VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY (see ENV_MASTER.md). Ask for the permission grant contextually, never on first load — a cold prompt is the fastest way to get blocked forever.
Background Sync is the other resilience primitive: defer a failed POST (an M-Pesa-adjacent action, saving progress) until connectivity returns, and the browser replays it from a sync event. Invaluable on flaky 2G/3G. Both push and sync run inside the same service worker you already registered.
Testing push locally: service workers and push need a secure origin. Instead of ngrok, run
next dev --experimental-httpsfor a locally-trusted HTTPS dev server (localhostalso counts as secure for SW registration). Verify the browser has notifications enabled.
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). Note: Next's own PWA guide now de-emphasizes a custombeforeinstallpromptbutton because it isn't cross-platform (no iOS Safari support) — it's still valid on Android where it converts well, just don't treat it as the universal install path. - 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 so precached assets are fingerprinted and a deploy can't serve a stale page. Keep the hand-written
sw.jsonly while the shell stays small and fixed. Match the package to your builder:@serwist/next(webpack) or@serwist/turbopack(the Next 16 default builder). - Harden the worker's headers. Serve
/sw.jswithCache-Control: no-cache, no-store, must-revalidateso clients always re-check for a new worker,Content-Type: application/javascript; charset=utf-8, and a tightContent-Security-Policy(default-src 'self'; script-src 'self'). Set these innext.config.jsheaders(). Never cache auth'd or user-specific responses in the shared SW cache (it isn't keyed per user).
Official docs:
- https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps
- https://web.dev/learn/pwa/service-workers
- https://serwist.pages.dev/docs/next/getting-started
- https://nextjs.org/docs/app/api-reference/file-conventions/metadata/manifest
- https://developer.chrome.com/docs/workbox/caching-strategies-overview