Progressive Web Apps with React and Next.js in 2026
Build installable, offline-ready PWAs with React and Next.js in 2026 — service workers, Web App Manifest, caching strategies, and push notifications covered end-to-end.
What a PWA Actually Is in 2026
Progressive Web Apps aren't new. Google started pushing the term in 2015, and the spec has been maturing ever since. But in 2026, the gap between a well-built PWA and a native app has genuinely closed for most use cases — install prompts work on iOS 16.4+, push notifications landed on Safari in 2023, and the Web Share API handles the last awkward gaps. The question isn't *can* you build a PWA anymore. It's whether you're building one correctly.
At the core, a PWA is just three things: a valid Web App Manifest (manifest.json), a registered Service Worker, and serving over HTTPS. That's it. Everything else — offline caching, background sync, push notifications, install prompts — is layered on top of that foundation. You'd be surprised how many projects claim PWA status but are missing one of those three.
Next.js makes the HTTPS and deployment side trivially easy if you're on Vercel or any modern host. The Manifest and Service Worker, though? Those still require deliberate configuration. This guide covers the full setup for a Next.js 14+ App Router project, including the parts most tutorials quietly skip.
Honestly, the biggest win from going PWA isn't the install button. It's the caching layer. A properly configured service worker can make your app feel instantaneous on repeat visits — even on a 3G connection — because the shell, fonts, and critical assets serve from the local cache while the network request fires in the background.
Setting Up the Web App Manifest
In Next.js 14 App Router, the cleanest way to generate your manifest is via the metadata API in app/manifest.ts. No plugin required, no static file to maintain — Next.js generates and serves /manifest.json automatically at build time.
// app/manifest.ts
import { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'My Empire App',
short_name: 'EmpireApp',
description: 'A fast, installable web app built with Next.js',
start_url: '/',
display: 'standalone',
background_color: '#0f0f13',
theme_color: '#7c3aed',
orientation: 'portrait-primary',
icons: [
{
src: '/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable',
},
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
screenshots: [
{
src: '/screenshots/desktop.png',
sizes: '1280x800',
type: 'image/png',
form_factor: 'wide',
},
],
};
}A few things matter here. display: 'standalone' removes the browser chrome when the app is installed — that's what makes it feel native. purpose: 'maskable' on the 192px icon tells Android to apply its adaptive icon system, which prevents your icon looking like a postage stamp in a white square. And the screenshots array? It unlocks the richer install dialog on Chrome and Edge since 2024 — without it, you get the stripped-down banner.
Worth noting: theme_color should match your <meta name="theme-color"> tag exactly, otherwise you get a jarring flash on Android when the splash screen transitions to your app. Set it once in the manifest and once in your root layout's <head>.
One more thing — the icons. You need at minimum a 192x192 and a 512x512 PNG. You can generate both sizes plus a maskable version from a single SVG in about 30 seconds using Squoosh or any PWA icon generator. Don't skip this step — missing icons is the single most common reason install prompts fail silently.
Service Workers: The Right Way to Register Them in Next.js
Next.js doesn't have native service worker support baked in, so you have two real options in 2026: roll your own or use next-pwa (the @ducanh2912/next-pwa fork, specifically — the original next-pwa package hasn't been maintained since 2022). In practice, for anything beyond a toy project, reach for the maintained fork. It handles Workbox configuration, precaching of Next.js chunks, and the tricky bits around App Router's output.
npm install @ducanh2912/next-pwa// next.config.js
const withPWA = require('@ducanh2912/next-pwa').default({
dest: 'public',
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
swcMinify: true,
disable: process.env.NODE_ENV === 'development',
workboxOptions: {
disableDevLogs: true,
},
});
module.exports = withPWA({
// your existing next config
});The disable: process.env.NODE_ENV === 'development' line is non-negotiable. A service worker intercepting requests during local development will make you want to throw your laptop. Cached responses from a previous build silently masking your changes is not a fun debugging session at 2am.
Quick aside: if you're rolling a custom service worker (say, for background sync or complex caching rules that Workbox can't express cleanly), drop the file in public/sw.js and register it manually in a client component. Next.js serves everything from public/ at the root, so /sw.js is the correct scope. Register it with navigator.serviceWorker.register('/sw.js') inside a useEffect with an 'serviceWorker' in navigator guard — older browsers and SSR environments don't know what a service worker is.
Caching Strategies That Actually Make Sense
Workbox ships five caching strategies and most developers pick the wrong one for the wrong resource. Here's the opinionated take: Cache First for fonts and static assets, Stale-While-Revalidate for API responses and HTML pages, Network First for anything that must be real-time (auth, checkout, live data). Mix and match per route — don't apply one strategy globally.
// public/sw.js (custom service worker example)
import { registerRoute } from 'workbox-routing';
import {
CacheFirst,
StaleWhileRevalidate,
NetworkFirst,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// Fonts — cache forever (they don't change)
registerRoute(
({ request }) => request.destination === 'font',
new CacheFirst({
cacheName: 'fonts-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
}),
],
})
);
// API — stale-while-revalidate for dashboard data
registerRoute(
({ url }) => url.pathname.startsWith('/api/dashboard'),
new StaleWhileRevalidate({
cacheName: 'api-dashboard',
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 60 * 5 }),
],
})
);
// Auth endpoints — always network, never cache
registerRoute(
({ url }) => url.pathname.startsWith('/api/auth'),
new NetworkFirst()
);Look, the ExpirationPlugin is easy to forget about, but skipping it means your service worker cache grows indefinitely. On a low-storage device, the browser will eventually evict the whole cache anyway — but by then you've already burned through the user's storage quota and probably caused silent failures. Keep maxEntries and maxAgeSeconds on every cache.
That said, the most impactful caching win is often the simplest one. Precaching your app shell — the HTML skeleton, critical CSS, and core JS bundle — means the app loads instantly on every repeat visit regardless of network conditions. next-pwa does this automatically for Next.js output, which is the main reason it's worth the dependency.
If you want to see what's actually in your caches, open Chrome DevTools → Application → Cache Storage. You'll see every named cache, the URLs stored, and the response headers. This is the fastest way to verify your caching rules are firing correctly — and to diagnose why users are seeing stale content after a deploy.
Offline Fallback Pages
A real PWA handles offline gracefully. Not with a browser error page — with your own UI. The pattern is simple: precache an /offline page, and in your service worker's fetch handler, catch navigation requests that fail due to network errors and return the cached offline page instead.
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-zinc-950 text-white">
<h1 className="text-3xl font-bold">You're offline</h1>
<p className="text-zinc-400 text-center max-w-xs">
Check your connection and refresh when you're back online.
Your cached content is still available.
</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-6 py-2 bg-violet-600 hover:bg-violet-500
rounded-lg font-medium transition-colors"
>
Try again
</button>
</div>
);
}For the service worker side, next-pwa handles offline fallback routing if you set fallbacks: { document: '/offline' } in the plugin config. If you're rolling your own, the pattern is a catch handler on fetch events that checks event.request.mode === 'navigate' and returns caches.match('/offline') on failure.
In practice, most users won't ever hit your offline page if you've set up precaching correctly. The point of it is the tail case — someone opens your app on the subway, loses signal, and tries to navigate to a new route that isn't cached. Without an offline fallback, they get a generic browser error and zero trust in your app. With it, they get your branded UI and a clear next action. The 30 minutes it takes to build is absolutely worth it.
Push Notifications and Background Sync
Push notifications in PWAs use the Web Push Protocol — your server sends a message to a push service (like Firebase Cloud Messaging or the browser's native endpoint), the push service wakes the service worker, and the service worker shows the notification. No app store, no native code. And as of Safari 16.4 on iOS/iPadOS, this works on Apple devices too — a barrier that kept many teams from committing to PWA for years.
// In your service worker
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? 'New update', {
body: data.body ?? '',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
tag: data.tag ?? 'default',
data: { url: data.url ?? '/' },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});Background Sync is the companion feature — it lets you queue actions (form submissions, data writes) while the user is offline and replay them automatically when connectivity returns. It's supported on Chrome and Edge, and partially on Firefox as of 2026. Safari still doesn't have it, which limits how much you can rely on it.
One more thing — permission UX matters enormously here. Triggering the push notification permission prompt on page load is one of the fastest ways to get an immediate dismiss and a permanently blocked state. Ask for permission after the user has taken a meaningful action — submitted a form, completed onboarding, clicked something that clearly implies they want updates. The conversion rate difference between immediate prompting and contextual prompting is usually 3-5x.
For the server side, web-push is the standard Node.js library and it integrates cleanly with Next.js Route Handlers. You generate VAPID keys once (web-push generateVAPIDKeys), store them in environment variables, and your /api/push/subscribe endpoint saves the subscription object to your database. From there you can trigger pushes from any server-side code.
Shipping and Testing Your PWA
Lighthouse is still the canonical PWA audit tool in 2026, but the scoring has shifted. Chrome 124+ removed the separate 'PWA' Lighthouse category and folds those checks into the main Performance, Accessibility, and Best Practices scores. To get a dedicated PWA report, run lighthouse https://yourapp.com --preset=experimental or use the DevTools Application panel's Manifest and Service Workers tabs for manual verification.
The four things to verify before shipping: manifest is valid and linked in <head>, service worker is registered and active (not just installing), icons exist at both required sizes, and the app is served over HTTPS with correct content-security-policy headers. The DevTools Application → Service Workers panel shows registration status, errors, and lets you simulate offline to test your fallback.
For end-to-end PWA testing, Playwright's browserType.launchPersistentContext supports service workers since Playwright 1.36, which means you can write automated tests that verify offline behavior, cache responses, and even install prompt triggering. If you're already using Playwright for page transitions or navigation tests, adding PWA-specific test cases takes maybe an afternoon.
If you want to see your final bundle impact before deploying, the react-performance-guide on this blog covers bundle analysis with @next/bundle-analyzer — useful for keeping your service worker precache list tight. And if you're building the UI itself, check out Empire UI's template library for pre-built Next.js starters that already include sensible PWA scaffolding alongside polished visual styles. It's a much better starting point than a blank create-next-app.
FAQ
Not natively — you need to add a manifest.ts file for the Web App Manifest and a library like @ducanh2912/next-pwa for the service worker layer. Next.js handles HTTPS and asset optimization automatically, which covers one of the three PWA requirements.
Yes. The app/manifest.ts export generates your manifest automatically, and @ducanh2912/next-pwa is fully compatible with App Router as of version 5.x. Register your service worker from a client component with a useEffect hook.
Yes, with caveats. iOS 16.4+ supports install-to-home-screen, push notifications, and most PWA APIs. Background Sync is still unsupported on Safari. Test on a real device — iOS behavior in desktop browser emulation is not reliable.
Cache First serves from cache and only hits the network if the cache misses — ideal for static assets that never change. Stale-While-Revalidate serves the cached version immediately and updates the cache in the background — better for content that changes occasionally but where speed matters more than freshness.