Back to Blog
Jan 5, 20263 min readOnuzulike Anthony

Building a PWA with Serwist and Next.js

Service worker config, caching strategies, install prompt, background sync, and update handling with @serwist/next.

Full-StackPWANext.jsSerwist

@serwist/next is the spiritual successor to next-pwa. It integrates Workbox-powered service workers into Next.js with less magic and more control. Here's how NodWatch is set up as a full PWA with offline support.

Installation

bash
npm install @serwist/next serwist

next.config.ts

ts
import type { NextConfig } from "next";
import withSerwist from "@serwist/next";
 
const nextConfig: NextConfig = {
  // your config
};
 
export default withSerwist({
  swSrc: "src/app/sw.ts",   // your service worker source
  swDest: "public/sw.js",   // output path
  disable: process.env.NODE_ENV === "development",
})(nextConfig);

Disabling in development avoids service worker caching interfering with hot reload.

The Service Worker (src/app/sw.ts)

ts
import type { PrecacheEntry } from "serwist";
import { Serwist } from "serwist";
import { defaultCache } from "@serwist/next/worker";
 
declare const self: ServiceWorkerGlobalScope & {
  __SW_MANIFEST: (PrecacheEntry | string)[];
};
 
const serwist = new Serwist({
  precacheEntries: self.__SW_MANIFEST,  // injected by @serwist/next
  skipWaiting: true,
  clientsClaim: true,
  navigationPreload: true,
  runtimeCaching: [
    ...defaultCache,
    {
      // Cache TMDB images for 30 days
      matcher: /^https:\/\/image\.tmdb\.org\//,
      handler: "CacheFirst",
      options: {
        cacheName: "tmdb-images",
        expiration: { maxAgeSeconds: 60 * 60 * 24 * 30 },
      },
    },
    {
      // Network-first for API routes
      matcher: /^https?:\/\/.*\/api\//,
      handler: "NetworkFirst",
      options: {
        cacheName: "api-cache",
        networkTimeoutSeconds: 5,
      },
    },
  ],
});
 
serwist.addEventListeners();

App Manifest

ts
// app/manifest.ts
import type { MetadataRoute } from "next";
 
export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "NodWatch",
    short_name: "NodWatch",
    description: "Stream movies and TV shows",
    start_url: "/",
    display: "standalone",
    background_color: "#000000",
    theme_color: "#000000",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
    ],
  };
}

Install Prompt Component

The browser fires beforeinstallprompt — save it and trigger on demand:

tsx
"use client";
import { useEffect, useState } from "react";
 
export function InstallPrompt() {
  const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
  const [showBanner, setShowBanner] = useState(false);
 
  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault();
      setDeferredPrompt(e);
      setShowBanner(true);
    };
    window.addEventListener("beforeinstallprompt", handler);
    return () => window.removeEventListener("beforeinstallprompt", handler);
  }, []);
 
  if (!showBanner) return null;
 
  return (
    <div className="fixed bottom-4 left-4 right-4 rounded-lg border border-white/10 bg-black p-4">
      <p className="text-sm text-white">Install NodWatch for offline access</p>
      <div className="mt-3 flex gap-2">
        <button
          onClick={async () => {
            deferredPrompt.prompt();
            const { outcome } = await deferredPrompt.userChoice;
            if (outcome === "accepted") setShowBanner(false);
          }}
          className="rounded bg-white px-4 py-2 text-sm text-black"
        >
          Install
        </button>
        <button onClick={() => setShowBanner(false)} className="text-sm text-white/60">
          Not now
        </button>
      </div>
    </div>
  );
}

Service Worker Update Handler

When a new service worker is waiting, prompt the user to refresh:

tsx
"use client";
import { useEffect } from "react";
 
export function SwUpdateHandler() {
  useEffect(() => {
    if (!("serviceWorker" in navigator)) return;
 
    navigator.serviceWorker.ready.then((registration) => {
      registration.addEventListener("updatefound", () => {
        const newWorker = registration.installing;
        newWorker?.addEventListener("statechange", () => {
          if (
            newWorker.state === "installed" &&
            navigator.serviceWorker.controller
          ) {
            // New SW waiting — prompt user
            if (confirm("A new version is available. Refresh?")) {
              newWorker.postMessage({ type: "SKIP_WAITING" });
              window.location.reload();
            }
          }
        });
      });
    });
  }, []);
 
  return null;
}

In sw.ts, handle the message:

ts
self.addEventListener("message", (event) => {
  if (event.data?.type === "SKIP_WAITING") {
    self.skipWaiting();
  }
});

Caching Strategies

| Strategy | Use case | | --- | --- | | CacheFirst | Images, fonts, static assets | | NetworkFirst | API responses, user data | | StaleWhileRevalidate | Pages, semi-static content | | NetworkOnly | Auth routes, payment flows |

The PWA layer on NodWatch means users can browse their watchlist and continue watching previous episodes without a connection — the service worker serves cached pages and TMDB images while the download system handles offline video.