Building a Production-Ready PWA in Next.js: Push Notifications & Background Sync

Progressive Web Apps blur the line between “website” and “installed app.” In Next.js, getting a PWA to a genuinely production-ready state means four things working together: a manifest, a service worker with a deliberate caching strategy, push notifications, and background sync for the moments your users are offline. Here's how all four fit together.
1. The manifest — making the app installable
The manifest.json tells the browser (and the OS install prompt) what your app is called, what it looks like, and how it should behave once installed.
// public/manifest.json
{
"name": "Ali Rahimi — Frontend Developer",
"short_name": "Ali Rahimi",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4338CA",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}Link it from the root layout, along with a theme-color meta tag:
// src/app/layout.tsx
export const metadata = {
manifest: "/manifest.json",
themeColor: "#4338CA",
};2. The service worker — the actual engine
The service worker is a script that runs in a separate thread, intercepts network requests, and decides what to do with them. Register it once, client-side, after the page has hydrated:
// src/lib/register-sw.ts
if (typeof window !== "undefined" && "serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(console.error);
});
}3. Caching strategies — the part people get wrong
Not all requests should be cached the same way. Picking the right strategy per request type is the difference between an app that feels instant and one that serves stale garbage.
Strategy | Behavior | Good for |
|---|---|---|
Cache First | Serve from cache; only hit network on a cache miss | Fonts, icons, versioned static assets |
Network First | Try the network; fall back to cache if it fails | HTML pages, frequently-updated API data |
Stale-While-Revalidate | Serve cached copy instantly, refresh cache in the background | Avatars, non-critical API responses |
Network Only | Never cache — always hit the network | Payments, auth endpoints, anything sensitive |
Cache Only | Never hit the network — cache must be pre-populated | App-shell assets after install |
A minimal hand-rolled service worker implementing two of these:
// public/sw.js
const STATIC_CACHE = "static-v1";
const APP_SHELL = ["/", "/offline", "/icons/icon-192.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL))
);
self.skipWaiting();
});
self.addEventListener("fetch", (event) => {
const { request } = event;
// Cache-first for static assets
if (request.destination === "image" || request.destination === "font") {
event.respondWith(
caches.match(request).then((cached) => cached || fetch(request))
);
return;
}
// Network-first for navigations (HTML pages), falling back to an offline page
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(() => caches.match("/offline"))
);
}
});In practice, reach for next-pwa or Serwist instead of hand-rolling this — they generate a tuned service worker from a config object and handle edge cases (like build-hash cache busting) you don't want to maintain yourself. Understanding the strategies above is what lets you configure them correctly.
4. Push notifications
Three moving parts: a subscription created in the browser, VAPID keys to authenticate your server, and a push event handler in the service worker.
// Client: ask permission and subscribe
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
});
// Send the subscription to your server to store against the user
await fetch("/api/push/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
});// public/sw.js — handle the actual push event
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? "New update", {
body: data.body,
icon: "/icons/icon-192.png",
data: { url: data.url ?? "/" },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});On the server, use a library like web-push with your VAPID key pair to send the actual notification payload to a stored subscription — this is what fires the push event above.
5. Background Sync — surviving flaky connections
Background Sync lets you defer a failed request (like a form submission made while offline) and have the browser automatically retry it once connectivity returns — even if the user has closed the tab.
// Client: on submit, if offline, register a sync tag instead of failing
async function submitMessage(data: FormData) {
try {
await fetch("/api/contact", { method: "POST", body: data });
} catch {
const registration = await navigator.serviceWorker.ready;
await saveToIndexedDB(data); // queue it locally first
await registration.sync.register("sync-contact-form");
}
}// public/sw.js
self.addEventListener("sync", (event) => {
if (event.tag === "sync-contact-form") {
event.waitUntil(flushQueuedFormsFromIndexedDB());
}
});Background Sync (the one-off kind above) is Chromium-only as of this writing — treat it as progressive enhancement, not your only offline strategy. Safari/Firefox users simply won't get automatic retry; make sure the manual retry path still works for them.
Checklist
manifest.json linked and validated (installability passes in Lighthouse)
Service worker registered after hydration, not blocking first paint
Deliberate caching strategy per request type — not one blanket rule
VAPID keys generated, subscription stored server-side, push handler tested on a real device
Background sync as enhancement, with a manual-retry fallback for unsupported browsers
Get these four pieces right and the difference is immediately obvious: instant repeat loads, notifications that bring users back, and forms that don't lose data on a flaky connection.
More posts

Authentication with better-auth in Next.js: A First-Hand Setup
The exact setup behind this site's own admin login: server config, the Next.js catch-all route handler, the client, and the two-layer middleware + layout protection that actually secures /admin.

Recharts in Next.js + Tailwind: Theming and Custom Styling
Making Recharts feel native to a Tailwind design system: correct Client Component boundaries, wiring colors to CSS variables for automatic dark mode, custom tooltips/legends, and handling RTL locales.