2026-05-25 23:05:39.720314+00:00

For years, the go-to solution for building Progressive Web Applications (PWAs) in Next.js was next-pwa. Built on top of Workbox, it served the community well during the Pages Router era. However, with the introduction of the Next.js App Router, React Server Components (RSC), and the recent release of React 19, the old tooling has begun to fracture. Deprecations, build-time conflicts, and type-system incompatibilities have made legacy setups increasingly fragile.

Enter Serwist—the modern, active, TypeScript-first successor to next-pwa. Designed specifically to support Next.js 13+ (up to 16) and React 19, Serwist provides a type-safe, optimized pipeline for service worker generation, precaching, and runtime caching.

This article details how to configure a cutting-edge PWA using Next.js 16, React 19, and Serwist. We will explore actual design patterns inspired by the real-world Tetangga App community platform, including critical gotchas such as resolving Firebase database sync conflicts and optimizing initial bundle weight.


Why Legacy PWA Tools Fail in Next.js 16

Legacy setups like next-pwa fail in modern ecosystems due to three main architectural changes:

  1. RSC Execution Contexts: Traditional Workbox-based webpack plugins are not designed to trace dependencies that span across Server Components and Client Components, leading to asset omission during precaching.
  2. TypeScript & Type Safety: Modern React 19 apps demand strict types. Legacy tools often rely on raw JavaScript templates or outdated type declarations for the Service Worker global scope (ServiceWorkerGlobalScope).
  3. Double-Registration & Hydration Mismatches: Standard PWA modules that automatically inject service worker registration scripts can cause DOM differences during hydration, resulting in React hydration errors.

Serwist resolves these issues by letting the developer explicitly control service worker registration on the client, maintaining type-safety across compiles, and decoupling service worker assets from the core Next.js webpack configuration when needed.


Step 1: Installing the Dependencies

To start, install Serwist's specialized Next.js and client window modules:

npm install @serwist/next @serwist/window
npm install -D serwist

Step 2: Configuring next.config.ts

In modern Next.js, our configuration uses TypeScript (next.config.ts). Here, we wrap our default config with withSerwistInit to intercept compiles, compile the custom service worker from TypeScript into standard ES modules, and map the outputs.

// next.config.ts
import type { NextConfig } from "next";
import withSerwistInit from "@serwist/next";

const withSerwist = withSerwistInit({
  swSrc: "src/app/sw.ts",   // Path to our custom TypeScript service worker
  swDest: "public/sw.js",   // Where the compiled service worker will be output
  register: false,          // Disables auto-registration (we will register manually)
});

const nextConfig: NextConfig = {
  output: "standalone",     // Ideal for containerized cloud environments (e.g., Cloud Run)
  outputFileTracingRoot: __dirname,
};

export default withSerwist(nextConfig);
Tip:
Setting register: false is crucial. It gives us absolute authority over when the service worker loads and how we prompt the user when a new build is deployed, eliminating hydration conflicts.

Step 3: Structuring the Service Worker (src/app/sw.ts)

Instead of writing a standard service worker that utilizes raw JSON configs, Serwist allows us to write a type-safe TypeScript worker.

Here is the implementation of src/app/sw.ts. It includes two highly important architectural overrides designed to resolve massive production headaches:

// src/app/sw.ts
import { defaultCache } from "@serwist/next/worker";
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist";
import { Serwist, NetworkOnly } from "serwist";

// 1. Declare strictly typed ServiceWorker global scope
declare global {
  interface WorkerGlobalScope extends SerwistGlobalConfig {
    __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
  }
}

declare const self: ServiceWorkerGlobalScope;

// 2. SOLVING THE FIREBASE OFFLINE GOTCHA:
// If you are using Firebase Auth, Firestore, or RTDB, you MUST exclude their API requests
// from the Service Worker caching. Caching Firestore's websocket / polling channels
// breaks Firestore's native offline synchronization.
const customCache = [
  {
    matcher: ({ url }: { url: URL }) => 
      url.hostname.includes("firestore.googleapis.com") || 
      url.hostname.includes("google.com"),
    handler: new NetworkOnly(), // Never intercept or cache Firestore/Google accounts requests
  },
  ...defaultCache,
];

const serwist = new Serwist({
  precacheEntries: self.__SW_MANIFEST,
  skipWaiting: false,          // Do NOT force immediate activation; wait for user confirmation
  clientsClaim: true,
  
  // 3. BYPASSING THE NAVIGATION PRELOAD REJECTION BUG:
  // Enabling navigationPreload in custom middleware / proxy configurations can trigger
  // fetch event promise rejection crashes on initial redirects. Keep it disabled.
  navigationPreload: false, 
  
  runtimeCaching: customCache as unknown as NonNullable<
    ConstructorParameters<typeof Serwist>[0]
  >["runtimeCaching"],
});

// Explicitly handle update messages sent by the client interface
self.addEventListener("message", (event) => {
  if (event.data && event.data.type === "SKIP_WAITING") {
    self.skipWaiting();
  }
});

serwist.addEventListeners();

Step 4: Non-Blocking Registration & Automated Updates (PwaUpdater.tsx)

To avoid bloat on initial page load (which ruins Core Web Vitals like Largest Contentful Paint and Interaction to Next Paint), we should register the service worker asynchronously within a React component using dynamic imports.

The PwaUpdater component does exactly this:

  1. It uses a dynamic import (import("@serwist/window")) to load the Serwist registration library only when the client reaches the browser runtime.
  2. It detects when a new version of the PWA is waiting in the background.
  3. It displays an elegant update banner requesting the user to upgrade.
  4. Upon confirmation, it sends a SKIP_WAITING command to activate the new bundle and restarts the window.
  5. // src/components/PwaUpdater.tsx
    "use client";
    
    import { useEffect, useState } from "react";
    
    export function PwaUpdater() {
      const [showPrompt, setShowPrompt] = useState(false);
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const [serwistInstance, setSerwistInstance] = useState<any>(null);
    
      useEffect(() => {
        // 1. Defend against non-browser contexts and automation runners
        if (
          typeof window !== "undefined" &&
          "serviceWorker" in navigator &&
          !navigator.webdriver
        ) {
          // 2. Asynchronously load Serwist window module to optimize initial TBT/LCP scores
          import("@serwist/window").then(({ Serwist }) => {
            const serwist = new Serwist("/sw.js", { scope: "/", type: "classic" });
            setSerwistInstance(serwist);
    
            // 3. Trigger alert when a new update is waiting in the background
            serwist.addEventListener("waiting", () => {
              setShowPrompt(true);
            });
    
            // 4. Force reload ONLY when the new SW successfully takes control
            serwist.addEventListener("controlling", () => {
              window.location.reload();
            });
    
            const promise = serwist.register();
            if (promise && typeof promise.catch === "function") {
              promise.catch((err: unknown) => {
                console.warn("Service worker registration failed or was blocked:", err);
              });
            }
          });
        }
      }, []);
    
      const handleUpdate = () => {
        setShowPrompt(false); // Immediate visual cleanup
    
        if (serwistInstance) {
          // Send the skipWaiting message which resolves the waiting SW and triggers "controlling"
          serwistInstance.messageSkipWaiting();
        }
      };
    
      if (!showPrompt) return null;
    
      return (
        <div className="fixed bottom-4 right-4 z-50 bg-blue-600 text-white px-6 py-4 rounded-lg shadow-xl flex flex-col sm:flex-row items-center gap-4 animate-in fade-in slide-in-from-bottom-4">
          <p className="m-0 font-medium">A new version of the app is available!</p>
          <button 
            onClick={handleUpdate}
            className="bg-white text-blue-600 px-4 py-2 rounded font-bold hover:bg-gray-100 transition-colors cursor-pointer"
          >
            Update Now
          </button>
        </div>
      );
    }

Step 5: Incorporating the Updater into layout.tsx

Finally, mount the PwaUpdater in your global layout. Since it only executes logic in the client runtime, it won't impact server-side rendering or create hydration mismatches.

// src/app/[lang]/layout.tsx
import { PwaUpdater } from "@/components/PwaUpdater";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <main>{children}</main>
        {/* Dynamic, non-blocking service worker manager */}
        <PwaUpdater /> 
      </body>
    </html>
  );
}

🚀 Pro-Tips and Real-World Gotchas

Warning:
Firestore Offline SDK conflicts: Never let a service worker cache requests to firestore endpoints. Doing so will freeze the websocket sockets and cause Firestore to throw sync loops. Always use the NetworkOnly handler in sw.ts for these hostnames.
Note:
HTTPS is Mandatory: Except for localhost, browsers will refuse to execute service workers over unsecured HTTP. Ensure your local dev script supports local SSL certificates (like --experimental-https in Next.js) so you can test features seamlessly on real phones over local networks.

Summary of Benefits

By combining Next.js 16, React 19, and Serwist, you achieve:

  1. Pristine Performance: Split-second registration via dynamic client-side imports.
  2. Absolute Reliability: Zero hydration mismatch errors.
  3. No Firestore Sync Freezes: Granular caching exceptions that keep real-time databases working smoothly online and offline.
  4. Type-Safe Service Workers: Absolute compiler guarantees for the global worker scope.

This architecture ensures your application will feel premium, operate offline, and run reliably across all modern devices.