Create-nextjs-pwa
Friday 22/07/2022
·6 min readA Progressive Web App is a regular web app with three additions: a web app manifest, a service worker, and HTTPS. The reward is "Add to Home Screen" on mobile, offline support, and the option of native-app-like permissions (push notifications, background sync). For Next.js, the next-pwa package handles 95% of the wiring — what's left is writing the manifest, dropping in icons, and being aware of the gotchas. Below: a complete PWA setup, deployed in 15 minutes.
What you actually need
manifest.json— describes the app to the browser- A service worker — caches assets for offline use
- Icons in
/public/— at minimum 192×192 and 512×512 PNGs - HTTPS — required for service workers (localhost is exempt for dev)
Everything else (theme colors, splash screens, OS-specific tweaks) is polish.
Install next-pwa
pnpm add next-pwa
Then configure it in next.config.js:
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
})
module.exports = withPWA({
reactStrictMode: true,
})
Key options:
dest: 'public'— emit the generatedsw.jsandworkbox-*.jsintopublic/so they're served at the site root.register: true— auto-injects the service-worker registration code into your pages.skipWaiting: true— new service workers activate immediately instead of waiting for all tabs to close. Trade-off: users get the new version faster, but mid-session updates can cause cache inconsistencies. For content sites it's usually fine.disable: ... === 'development'— service workers + Next.js fast refresh + cache invalidation = pain. Disable in dev and only test PWA features inpnpm build && pnpm start.
Write the manifest
Create public/manifest.json:
{
"name": "My App",
"short_name": "MyApp",
"description": "What your app does in one sentence.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Key fields:
namevsshort_name—nameshows in install dialogs,short_nameshows under the home-screen icon (12 chars max recommended).display: standalone— hides the browser chrome when launched from home screen. Other options:fullscreen,minimal-ui,browser.theme_color— colors the OS status bar on Android.icons— at least 192×192 and 512×512."purpose": "any maskable"lets Android crop the icon to its system shape (circle, rounded square, etc.). Maskable icons need a safe zone in the center 80% — see maskable.app to test.
Link the manifest from _document.tsx
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="en">
<Head>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
The apple-touch-icon link is iOS-specific — Safari ignores manifest.json icons and uses this instead.
Build and test
pnpm build && pnpm start
Open http://localhost:3000 in Chrome → DevTools → Application tab:
- Manifest section should show your app details with no errors.
- Service Workers should show one registered worker, status "activated and running."
- Storage → Cache Storage should show one or more caches with your assets.
Test "Add to Home Screen":
- Chrome desktop: the install icon (
⊕) appears in the URL bar when criteria are met. - Chrome Android: visit the site, tap menu → "Install app."
- iOS Safari: tap Share → "Add to Home Screen" (works without a service worker but uses the manifest).
Offline support
next-pwa uses Workbox under the hood with sensible defaults: static assets (JS, CSS, images) get cached on first request; subsequent visits work offline as long as you've visited each page once.
For finer control, add a runtimeCaching array in your next-pwa config:
const withPWA = require('next-pwa')({
dest: 'public',
runtimeCaching: [
{
urlPattern: /^https?.*/,
handler: 'NetworkFirst',
options: {
cacheName: 'all-cache',
networkTimeoutSeconds: 10,
},
},
],
})
Workbox strategies:
| Strategy | Behavior |
|---|---|
| CacheFirst | Check cache, network only on miss. Fastest but stale. |
| NetworkFirst | Check network, fall back to cache. Fresh but slower. |
| StaleWhileRevalidate | Serve cache immediately, refresh in background. Best UX for most assets. |
| NetworkOnly | Bypass cache. Use for /api/ routes. |
Edge cases worth knowing
- Service workers cache aggressively. During development, an old service worker can serve a months-old version of your app. Always test PWA changes in an Incognito window, or "Unregister" the worker from DevTools first.
- HTTPS is mandatory for service workers in production. localhost is exempt for development. If you self-host, you need a TLS cert — Let's Encrypt + Caddy or Nginx is the standard setup.
- iOS Safari is the limiting platform. It supports manifest + Add to Home Screen + a subset of service-worker features, but no push notifications (until iOS 16.4+) and stricter cache limits.
- Updating an installed PWA can be tricky — users keep the cached version until the service worker fetches new files. With
skipWaiting: true, this happens on next visit; without it, on next browser restart. next-pwadoesn't generate asw.jsfor the App Router. For App Router projects, use@ducanh2912/next-pwa(a maintained fork) orserwist.
FAQ
Do I need next-pwa for a Next.js PWA?
No — you can write a manifest and a service worker by hand. next-pwa wraps Workbox and Next.js's build pipeline so you don't have to, and it handles the most common gotchas (dev disabling, cache invalidation, etc.). It's the path of least resistance.
Why does my service worker not update?
By default, the browser checks for a new service worker every 24 hours. If your sw.js content has changed, it'll update on the next page load. Force an update via navigator.serviceWorker.getRegistration().then(r => r.update()), or just unregister via DevTools during development.
Can I have an offline-only page?
Yes — pre-cache an /offline route and configure a fallback in runtimeCaching. When the network is unreachable and the requested page isn't cached, the service worker serves /offline instead. Workbox's precacheAndRoute plus a NavigationRoute with a catch handler is the standard pattern.
Does PWA work with Next.js App Router?
The original next-pwa was Pages-Router-only. For App Router, use the @ducanh2912/next-pwa fork or serwist — both implement the same Workbox integration with App-Router-compatible build hooks.