Progressive Web Apps with Next.js: A Hands‑On Guide to Service Workers, Caching, and Offline UX
Introduction
Progressive Web Apps (PWAs) combine the reach of the web with the reliability and performance of native applications. When built with Next.js, a framework that already handles server‑side rendering (SSR), routing, and API routes, adding PWA capabilities becomes a matter of wiring a few pieces together: a manifest, a service worker, and some caching strategies.
This article walks you through a practical, end‑to‑end implementation:
- Setting up a Next.js project for PWA support
- Creating a custom service worker with Workbox
- Caching static assets, API responses, and dynamic pages
- Handling updates and offline fallback
By the end you’ll have a production‑ready PWA that works on desktop and mobile browsers, can be installed on the home screen, and gracefully degrades when the network is unavailable.
1. Project Scaffold
Start with a fresh Next.js app (v13+). If you already have a project, skip to the next step.
npx create-next-app@latest next-pwa-demo
cd next-pwa-demo
npm install
Add the two dependencies that will power the PWA:
npm i next-pwa workbox-build
- next-pwa – a thin wrapper that injects the manifest and registers the service worker in production.
- workbox-build – gives us fine‑grained control over the generated service worker file.
2. Manifest and Basic PWA Configuration
Create a public/manifest.json file. This is the metadata the browser uses when the user adds the app to their home screen.
{
"name": "Next.js PWA Demo",
"short_name": "NextPWA",
"description": "A sample Progressive Web App built with Next.js",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0070f3",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Add the icons referenced above to public/icons/. They can be generated with any image‑to‑icon tool (e.g., pwa-asset-generator).
Now configure next-pwa in next.config.js:
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
// Disable in dev to avoid noisy reloads
disable: process.env.NODE_ENV === 'development',
// Register the service worker only after the page is fully loaded
register: true,
// Use a custom service worker we will generate later
swSrc: 'service-worker.js',
});
module.exports = withPWA({
// Any other Next.js config you need
});
The dest: 'public' option tells the plugin to copy the generated sw.js into the public folder where browsers can fetch it.
3. Writing a Custom Service Worker
While next-pwa can generate a default worker, a custom file lets you tailor caching strategies. Create service-worker.js at the project root (outside pages and public).
/* service-worker.js */
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// 1️⃣ Pre‑cache the assets generated by Next.js (HTML, CSS, JS bundles)
precacheAndRoute(self.__WB_MANIFEST || []);
// Remove old caches automatically
cleanupOutdatedCaches();
// 2️⃣ Cache Google Fonts (Cache First, long max age)
registerRoute(
({ url }) => url.origin === 'https://fonts.googleapis.com' ||
url.origin === 'https://fonts.gstatic.com',
new CacheFirst({
cacheName: 'google-fonts',
plugins: [
new ExpirationPlugin({
maxEntries: 30,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
}),
],
})
);
// 3️⃣ API data – Network First, fallback to cache
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-responses',
networkTimeoutSeconds: 5,
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 60 * 60, // 1 hour
}),
],
})
);
// 4️⃣ Images – Stale‑While‑Revalidate
registerRoute(
({ request }) => request.destination === 'image',
new StaleWhileRevalidate({
cacheName: 'image-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
}),
],
})
);
// 5️⃣ Offline fallback page
const OFFLINE_FALLBACK_PAGE = '/offline.html';
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).catch(() => caches.match(OFFLINE_FALLBACK_PAGE))
);
}
});
Why These Strategies?
| Resource | Strategy | Reason |
|---|---|---|
| Static bundles | precacheAndRoute |
Known at build time, must be served instantly. |
| Google Fonts | CacheFirst |
Fonts rarely change; long TTL reduces network trips. |
| API endpoints | NetworkFirst |
Fresh data is preferred, but fallback to cache when offline. |
| Images | StaleWhileRevalidate |
Show the cached image instantly, then update in the background. |
| HTML navigation | Custom offline fallback | Guarantees a graceful UI when the user is completely offline. |
4. Generating the Precaching Manifest
Workbox needs a list of files to precache. Add a small build script to package.json:
{
"scripts": {
"dev": "next dev",
"build": "next build && node generate-sw-manifest.js",
"start": "next start"
}
}
Create generate-sw-manifest.js:
// generate-sw-manifest.js
const { injectManifest } = require('workbox-build');
const path = require('path');
(async () => {
const { count, size, warnings } = await injectManifest({
swSrc: path.join(__dirname, 'service-worker.js'),
swDest: path.join(__dirname, 'public', 'sw.js'),
globDirectory: path.join(__dirname, '.next'),
globPatterns: [
'**/*.{js,css,html,svg,png,jpg,webp}'
],
// Exclude source maps in production
globIgnores: ['**/*.map'],
});
if (warnings.length) {
console.warn('Workbox generated warnings:', warnings);
}
console.log(`✅ Service worker generated. Precached ${count} files, total size ${size} bytes.`);
})();
Running npm run build now produces a public/sw.js that contains the precache manifest (self.__WB_MANIFEST). The next-pwa plugin will serve this file as the service worker.
5. Adding an Offline Fallback Page
Create a simple page that informs the user they are offline. Place it in pages/offline.js:
// pages/offline.tsx
export default function Offline() {
return (
<main style={{ padding: '2rem', textAlign: 'center' }}>
<h1>You're offline</h1>
<p>
The app is unable to reach the network. Some features may be unavailable,
but you can still browse previously loaded content.
</p>
</main>
);
}
Next, generate a static HTML version that the service worker can serve. Add a script to export it after the build:
{
"scripts": {
"export:offline": "next export -p 3001 && cp out/offline.html public/offline.html"
}
}
Run npm run export:offline after the main build, or integrate it into a post‑build step.
6. Testing the PWA Locally
Next.js’s development server does not serve the service worker. To test the full PWA stack, start a production server:
npm run build
npm start
Open http://localhost:3000 in Chrome, then open DevTools → Application → Service Workers. You should see sw.js registered, with the precached assets listed.
Simulating Offline
- In DevTools, go to Network → Offline.
- Refresh the page. The offline fallback page should appear for navigation requests, while images and previously visited pages load from cache.
7. Updating the Service Worker
When you deploy a new version, the service worker file changes, triggering an update. However, browsers keep the old worker active until all tabs are closed. To provide a smoother experience, you can prompt the user to refresh.
Add the following snippet to a top‑level component (e.g., pages/_app.tsx):
import { useEffect } from 'react';
function MyApp({ Component, pageProps }) {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
// The new SW has taken control – reload the page
window.location.reload();
});
}
}, []);
return <Component {...pageProps} />;
}
export default MyApp;
This simple listener forces a reload as soon as the new worker becomes active, ensuring users always see the latest assets without manual cache clearing.
8. Real‑World Example: Offline‑First Blog
Imagine a blog built with Next.js that fetches posts from a headless CMS via /api/posts. By applying the strategies above:
- Static pages (
/,/about) are precached, so the site loads instantly even without a network. - API responses for posts are cached for an hour. When a user revisits a post while offline, the cached JSON is rendered, giving a near‑real‑time reading experience.
- Images inside posts are served from the
StaleWhileRevalidatecache, so the first view is instant, and newer images are fetched in the background.
The result is a blog that feels native: users can add it to their home screen, open it offline, and still read previously loaded articles.
9. Common Pitfalls & How to Avoid Them
| Pitfall | Symptom | Fix |
|---|---|---|
| Service worker not registering | No sw.js in Application tab. |
Ensure next-pwa is not disabled (process.env.NODE_ENV !== 'development') and that swSrc points to the generated file. |
| Cache never updates | Old assets keep showing after a deploy. | Verify that injectManifest runs after each next build. Increment the revision field automatically by using the default self.__WB_MANIFEST. |
| CORS errors for API caching | NetworkFirst fails with “opaque response”. |
Make sure API routes send proper CORS headers (Access-Control-Allow-Origin: * or your domain) or keep the API calls same‑origin. |
| Large service worker size | Slow registration, warnings in console. | Limit globPatterns to only the files you truly need. Use workbox-webpack-plugin for more granular control if the bundle grows. |
| Offline fallback not showing | Navigation still shows a network error. | Confirm that offline.html is present in public/ and that the fetch handler checks event.request.mode === 'navigate'. |
10. Deploying to Production
Most hosting platforms (Vercel, Netlify, Cloudflare Pages) serve static assets from the public folder automatically, so the service worker will be available at /sw.js. When deploying:
- Run
npm run build(which also generatessw.js). - Push the generated
.nextandpublicdirectories. - Verify the manifest and service worker URLs are reachable (
https://your-domain.com/manifest.json,https://your-domain.com/sw.js).
If you use a CDN that caches HTML aggressively, add a Cache-Control: no-store header for /offline.html to guarantee the fallback page is always fresh.
Conclusion
Progressive Web Apps are no longer a niche experiment; they are a practical way to improve reliability, performance, and user engagement for any Next.js site. By:
- Adding a manifest and icons,
- Writing a custom service worker with Workbox,
- Choosing appropriate caching strategies for static assets, API data, and images, and
- Providing an offline fallback and update flow,
you can ship a production‑grade PWA with less than 100 lines of service‑worker code.
The patterns shown here are deliberately minimal yet extensible. As your app grows, you can introduce background sync, push notifications, or even runtime caching for third‑party scripts—all built on the same foundation.
Happy coding, and enjoy the native‑like experience you just gave your users!
Member discussion