Building a PWA with Serwist and Next.js
Service worker config, caching strategies, install prompt, background sync, and update handling with @serwist/next.
@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
npm install @serwist/next serwistnext.config.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)
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
// 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:
"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:
"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:
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.