Building a Progressive Web App With Next.js: A Step-by-Step Overview
3 min read
When our healthcare platform needed to be installable on patients' phones, the assumed answer was "so we build a native app." We shipped a Progressive Web App instead — installable from the browser, offline-tolerant, no app store between us and our users — and the surprise was how little work the PWA layer itself required. If you have a working Next.js app, you're three concerns away from a PWA: a manifest, a service worker, and honest testing. Here's the overview I wish I'd had.
Why it takes less effort than people think
A PWA isn't a different kind of app — it's your web app plus metadata (so the OS can install it) plus a service worker (so it tolerates bad networks). The hard parts of any app — the product itself, the performance, the accessibility — you've already built. What the PWA layer buys you: a home-screen icon, a standalone window without browser chrome, resilience on flaky connections, and zero app-store review cycles. For our patient portal, that last one mattered most: shipping a fix at 4pm instead of "pending review."
Step 1: The web app manifest
The manifest is a JSON file describing your app to the operating system:
{
"name": "MatchRite Patient Portal",
"short_name": "MatchRite",
"description": "Access your care plan, results, and providers.",
"start_url": "/",
"display": "standalone",
"background_color": "#F6F5F0",
"theme_color": "#1B1E23",
"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"
}
]
}In Next.js App Router, either drop manifest.json in public/ and link it, or —
cleaner — export it from app/manifest.ts and Next generates and links it for you. The
details that trip teams up: display: "standalone" is what removes browser chrome;
you need real 192px and 512px icons; and the maskable icon variant (safe zone
padding, no transparency tricks) is what keeps your icon from being awkwardly cropped
inside Android's adaptive shapes.
Step 2: A service worker for offline support
The service worker is a script the browser runs between your app and the network. For a first PWA, resist the urge to cache everything — a small, understandable strategy beats a clever one you can't debug:
// public/sw.js — minimal: precache the shell, serve it when offline
const CACHE = 'app-shell-v1';
const SHELL = ['/', '/offline'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
});
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() =>
caches.match(event.request).then((hit) => hit ?? caches.match('/offline'))
)
);
}
});Register it once on the client (a tiny component in your root layout):
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}Network-first for navigation with an offline fallback is the strategy I recommend
starting with: users always get fresh content when online, and a designed /offline
page (not a browser error) when they're not. On library options: next-pwa and Serwist
generate Workbox-based workers with precaching for you, and they're fine — but for a
first implementation, twenty hand-written lines you understand will teach you more than
a config block, and there's less magic when caching goes wrong. Two rules either way:
version your caches (bump v1, clean old caches on activate) and never cache
API responses carelessly — for healthcare data, we cached the shell and nothing
personal.
Step 3: Test on a real device (the step everyone skips)
Desktop DevTools lies to you politely. It'll confirm the manifest parses and the worker registers — it can't tell you that your install prompt never fires on Android, that iOS Safari treats PWAs differently (no install prompt at all — users add manually via the share sheet; test that flow), or that your "fast" shell takes six seconds on a mid-range phone over hotel Wi-Fi. The minimum honest pass: Lighthouse's PWA checks, then Chrome on a real Android device (install, launch standalone, airplane mode, does the offline page appear?), then Safari on a real iPhone. Requirements to remember: HTTPS is mandatory (localhost excepted), and the manifest + worker must both be in place before browsers consider the app installable.
What shipping one taught me
The PWA layer took days; the product work it exposed took longer — because installability raises expectations. An installed app that takes four seconds to open feels broken in a way a website doesn't, so performance discipline (bundle-conscious component boundaries, aggressive caching) became non-negotiable. Treat the manifest and worker as the easy 20%, budget your care for the experience itself, and a PWA is the highest-leverage "native-ish" move a web team can make — one codebase, every platform, your own release schedule.