Back to blog

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

July 24, 20264 min read
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

  1. manifest.json linked and validated (installability passes in Lighthouse)

  2. Service worker registered after hydration, not blocking first paint

  3. Deliberate caching strategy per request type — not one blanket rule

  4. VAPID keys generated, subscription stored server-side, push handler tested on a real device

  5. 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.