Frameworks

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.

Found this useful?

Share

I post shorter takes on frameworks & architecture and frontend leadership on LinkedIn — follow along there, or get in touch if you're working on something related.