Why turn your Next.js app into a PWA in 2026?
If you have a Next.js app that people open in a browser, a Next.js PWA is probably the cheapest distribution upgrade you can make: one deploy gives you a home-screen icon, a standalone window with no browser chrome, offline-capable pages, and (where supported) push notifications. No app-store review, no native codebase, no per-user download.
But most tutorials on this topic are quietly rotting. The dominant search results still tell you to install next-pwa — a package that has not been maintained since 2022 and does not work with modern Next.js builds (it is a webpack-only plugin; Next.js 16 builds with Turbopack by default). This guide does it the way that actually works today: Next.js’s native app/manifest.ts for the web app manifest, and Serwist — the maintained Workbox-based successor — for the service worker. Facts below are verified against the Next.js docs and Serwist’s documentation as of October 2026.
What a PWA actually gives you (and what it does not)
A Progressive Web App is still just your website — plus two browser features: a web app manifest that describes the app (name, icons, colours, start URL) and a service worker that runs in the background and intercepts network requests. With those two pieces you get:
- Installability: an “Install app” prompt in Chrome/Edge, or “Add to Home Screen” on iOS, opening in a standalone window.
- Offline support: precached pages and assets still load with no connection; runtime caching makes repeat visits instant.
- Push notifications: via the Web Push API with a service worker — iOS supports this for installed PWAs since iOS 16.4.
What it is not: a substitute for a native app where you need background execution, Bluetooth, NFC, or platform-specific APIs. For dashboards, blogs, docs, admin panels, and content apps, it is usually enough.
Prerequisites
- A Next.js 15 or 16 project using the App Router (this guide uses App Router conventions; the manifest file lives at
app/manifest.ts). - Node.js 20.9+ (Next.js 16’s minimum).
- HTTPS in production — service workers only run on secure contexts. On
localhostthey work over plain HTTP, so local development is fine.
Step 1: Create the web app manifest with app/manifest.ts
Next.js ships native manifest support through the Metadata API — you return a typed object and Next.js serves it as /manifest.webmanifest. No library needed, no <link rel="manifest"> tag to manage. The fields that matter for installability are name, short_name, start_url, display, and icons (covered in the next step).
// app/manifest.ts
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
id: '/',
name: 'ShipIt Notes — a fast PWA for dev notes',
short_name: 'ShipIt Notes',
description: 'Offline-capable notes app built with Next.js',
start_url: '/',
scope: '/',
display: 'standalone',
orientation: 'portrait',
background_color: '#0a0a0a',
theme_color: '#BB1919',
icons: [
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
}
}
The purpose: 'maskable' entry is worth including: Android devices crop icons to their own shapes, and maskable icons survive that without your logo being decapitated. Keep start_url pointing at a route that loads fast and renders something meaningful offline (the homepage or a dedicated app shell).
Step 2: Generate the icons
Chrome’s install prompt needs at least one icon at 192px and one at 512px. In practice, generate the full set — icon-192x192.png, icon-512x512.png, plus an apple-touch-icon (180×180) for iOS, which does not use the manifest the same way Chrome does.
The fastest path: start from a 1024×1024 source and use pwa-asset-generator, which outputs every size plus the maskable padding calculations. One community write-up verified version 8.1.5 generating the whole set from a single public/favicon.ico. Whatever you use, serve the icons from public/ and confirm each path in the manifest returns a 200 — a broken icon URL is one of the most common reasons the install prompt never appears.
Step 3: Add metadata for the install experience
The manifest covers Chrome and Edge, but iOS Safari still reads HTML meta tags for home-screen icons and the theme colour. Add them to your root layout via the Next.js Metadata API:
// app/layout.tsx
import type { Metadata, Viewport } from 'next'
export const metadata: Metadata = {
title: 'ShipIt Notes',
description: 'Offline-capable notes app built with Next.js',
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'ShipIt Notes',
},
}
export const viewport: Viewport = {
themeColor: '#BB1919',
viewportFit: 'cover',
}
Then place an apple-touch-icon at app/apple-icon.png (or app/apple-touch-icon.png depending on your Next.js version’s icon conventions) so iOS picks it up automatically. For reference, the official Next.js PWA guide demonstrates this exact metadata pattern.
Step 4: Add the service worker with Serwist
This is where stale tutorials hurt you most. Do not install next-pwa. It is archived/unmaintained, webpack-only, and incompatible with current Next.js builds — several real-world repos documented their service worker silently not being generated at all because the plugin never ran under Turbopack. The maintained replacement is Serwist, a TypeScript-first fork of Workbox’s ideas designed for the Next.js App Router.
Install the packages:
npm install -D @serwist/next serwist
One version note that matters in 2026: @serwist/next is the build integration, serwist is the core library you import inside the worker. If your project builds with Turbopack (the Next.js 16 default), Serwist offers a separate @serwist/turbopack path — check the Serwist docs for your bundler, because the webpack-oriented config below assumes a webpack build.
Wrap your Next.js config:
// next.config.ts
import withSerwistInit from '@serwist/next'
import type { NextConfig } from 'next'
const withSerwist = withSerwistInit({
swSrc: 'app/sw.ts', // your service worker source
swDest: 'public/sw.js', // compiled output (must be in public/)
// Never let a service worker cache your dev server:
disable: process.env.NODE_ENV === 'development',
})
const nextConfig: NextConfig = {
// your existing config
}
export default withSerwist(nextConfig)
Now write the worker itself. defaultCache from @serwist/next/worker gives you sensible defaults (precaching of your build assets, sane runtime caching for images and fonts), and the fallbacks entry serves an offline page when a document request fails:
// app/sw.ts
/// <reference lib="webworker" />
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,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache,
fallbacks: {
entries: [
{
url: '/offline',
matcher({ request }) {
return request.destination === 'document'
},
},
],
},
})
serwist.addEventListeners()
A few lines here do heavy lifting: skipWaiting: true plus clientsClaim: true mean a newly deployed worker takes over immediately instead of waiting for all tabs to close — your users get the fresh version on their next visit, not their next browser restart. navigationPreload: true lets the browser start fetching the page in parallel with worker startup. And disable in the config is non-negotiable: a service worker registered against next dev will serve you stale pages during hot reload and make you question your sanity.
Add the generated files to .gitignore — they are build artefacts, not source:
# Serwist generated files
public/sw*
public/swe-worker*
And add the webworker types to tsconfig.json so self and the Serwist globals type-check:
{
"compilerOptions": {
"types": ["@serwist/next/typings"],
"lib": ["webworker"]
}
}
Step 5: Build the offline fallback page
The /offline route in the worker above needs to exist. Make it a static page with no data fetching so it always renders, even with the network cable pulled:
// app/offline/page.tsx
export default function OfflinePage() {
return (
<main>
<h1>You are offline</h1>
<p>
This page needs a connection, but everything you cached
while online is still available.
</p>
</main>
)
}
If you want the fallback in the precache manifest rather than relying on runtime navigation caching, pass additionalPrecacheEntries: [{ url: '/offline', revision: 'your-revision' }] to withSerwistInit.
Step 6: Choose caching strategies per resource type
Default caching is fine until it is not. The classic failure: caching an authenticated API response and serving one user’s dashboard to another. Serwist supports the standard Workbox-style strategies — pick deliberately:
- Cache First — images, fonts, long-lived static assets. Fastest repeat loads; accept that content can be stale until expiry.
- Stale While Revalidate — JS/CSS bundles, public pages. Instant response from cache, fresh copy fetched in the background for next time. This is the right default for most of a marketing/content site.
- Network First — your API reads. Fresh when online, cached copy only as a fallback when offline.
- Network Only — authentication endpoints, mutations, payments, anything session-dependent or cookie-sensitive. This must be explicit: never let an API route fall into a generic same-origin page cache. One repo’s post-mortem found a greedy route matcher accidentally caching
/api/*responses as pages — pin API traffic toNetworkOnlyby matching the parsed pathname.
The rule of thumb: cache what is identical for every user, fetch what depends on who is asking.
Step 7: Add the install prompt (Chrome/Edge)
Chromium fires a beforeinstallprompt event when your site meets the installability criteria: HTTPS, a valid manifest with identity/start URL/display metadata, suitable icons, and a registered service worker. Capture the event and show your own install button instead of waiting for the browser’s menu:
// components/install-button.tsx
'use client'
import { useEffect, useState } from 'react'
export default function InstallButton() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault() // stop Chrome's automatic mini-infobar
setDeferredPrompt(e)
}
window.addEventListener('beforeinstallprompt', handler)
return () => window.removeEventListener('beforeinstallprompt', handler)
}, [])
if (!deferredPrompt) return null
return (
<button
onClick={async () => {
deferredPrompt.prompt()
await deferredPrompt.userChoice
setDeferredPrompt(null)
}}
>
Install app
</button>
)
}
Hide the button (or the whole banner) once installed: window.matchMedia('(display-mode: standalone)').matches tells you the app is already running as an installed PWA.
Step 8: Handle iOS separately
iOS does not fire beforeinstallprompt — there is no programmatic install prompt on Safari. Users install through Share → “Add to Home Screen”, so your job is to show them how:
// components/ios-install-hint.tsx
'use client'
import { useEffect, useState } from 'react'
export default function IosInstallHint() {
const [show, setShow] = useState(false)
useEffect(() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
setShow(isIOS && !isStandalone)
}, [])
if (!show) return null
return (
<p>
To install this app on your iPhone: tap the Share button,
then choose "Add to Home Screen".
</p>
)
}
Three iOS-specific gotchas to bake in: icons — iOS uses the apple-touch-icon, not your manifest icons, for the home-screen tile, so ship one. Push — web push on iOS only works for installed, standalone PWAs on iOS 16.4+, and subscriptions can disappear silently; re-check pushManager.getSubscription() on startup. Debugging — Safari Web Inspector cannot inspect installed home-screen PWAs, so add an in-app diagnostics path (a hidden debug route or an embedded console like Eruda) if you ever need to troubleshoot in the field.
Step 9: Test it properly
- Build, don’t dev: run
npm run build && npm run start. Your service worker is disabled in dev (by thedisableflag), so testing againstnext devtells you nothing. - DevTools → Application: check that
/manifest.webmanifestparses with no errors, that icons load, and that/sw.jsis registered and activated. The Application panel also has a “Bypass for network” checkbox — keep it in mind when the worker keeps serving you a cached build during testing. - Offline test: in the Network tab, toggle “Offline” and reload. Your precached pages and the
/offlinefallback should render. - HTTPS locally: some features (push in particular) need a real secure context;
next dev --experimental-httpscovers local testing. - Audit: note that Chrome removed the dedicated PWA category from Lighthouse in version 126 — there is no PWA “score” anymore. Rely on the Application panel checks and the manifest/installability validators instead of hunting for the old category.
What not to do
- Do not install
next-pwaor@ducanh2912/next-pwafor new projects. Both are effectively abandoned; the maintainers’ own ecosystem moved to Serwist. Stale blog posts are the only thing keeping it alive. - Do not cache authenticated traffic by accident. Route matchers are greedy; keep
/api/*and session-dependent navigations onNetworkOnly. - Do not register the worker in development.
disable: process.env.NODE_ENV === 'development'exists for a reason — stale caches during dev are a debugging nightmare. - Do not expect
beforeinstallprompton iOS. Build the manual iOS hint; it is the only install UX Apple gives you.
Putting it together
That is the complete loop: a native app/manifest.ts plus icons makes your Next.js app installable; a Serwist worker with precaching, runtime caching, and an offline fallback makes it survive bad networks; a small install-button component plus an iOS hint makes people actually install it. The whole thing is maybe 60 lines of config and one small worker file — and it is the closest thing to free distribution a web app gets. Written October 2026; Serwist 9.x and Next.js 16 verified at time of writing.
Further Reading & References
- Next.js official PWA guide — manifest, push notifications, service workers, install prompts (last updated April 2026).
- Serwist documentation — the maintained next-pwa successor; webpack and Turbopack Next.js integrations.
- Making PWAs installable (MDN) — the installability criteria, field by field.
- Serwist + Next.js best practices (community skill) — config matrix, tsconfig, and gotcha list.
- Dynamically generating PWA icons with Next.js 16 + Serwist — environment-based icon generation and Serwist setup walkthrough.
- Building offline apps with Next.js and Serwist (dev.to) — caching-strategy comparison table for offline apps.
- PWA setup guide for Next.js 15 (dev.to) — step-by-step manifest, middleware, and service-worker registration walkthrough.



