shinyaz.com

Migrating Next.js PWA to @serwist/turbopack for Faster Builds

Table of Contents

Background

This blog uses Next.js + Serwist for PWA support. Previously, the Service Worker was built using a combination of @serwist/next (a webpack plugin) and a custom esbuild script. This setup had several issues:

  • The esbuild script (scripts/build-sw.mjs) didn't inject a precache manifest, so precaching wasn't functional
  • The build pipeline was a 3-stage process: velite build → esbuild → next build
  • Turbopack couldn't be used as the default bundler since @serwist/next depends on webpack

Migrating to @serwist/turbopack solved all of these at once.

What is @serwist/turbopack?

@serwist/turbopack is Serwist's Turbopack-compatible package. Instead of a webpack plugin, it uses a Next.js Route Handler to compile the SW at build time and automatically inject the precache manifest.

Here's how the two approaches compare:

Aspect@serwist/next (webpack)@serwist/turbopack
SW buildWebpack plugin auto-generatesRoute Handler via esbuild
SW URL/sw.js (output to public/)/serwist/sw.js (Route Handler path)
Precache manifestInjected by webpack pluginInjected by createSerwistRoute
SW registrationAutomatic via register optionExplicit via SerwistProvider component
Turbopack supportNoNative

Migration Steps

1. Update Dependencies

npm uninstall @serwist/next
npm install -D @serwist/turbopack esbuild

@serwist/turbopack uses esbuild internally to bundle the SW, so esbuild is required as a peer dependency.

2. Add withSerwist to next.config.ts

import type { NextConfig } from "next";
import { withSerwist } from "@serwist/turbopack";
 
const nextConfig: NextConfig = {
  // ...existing config
};
 
export default withSerwist(nextConfig);

withSerwist is a simple wrapper that adds esbuild to serverExternalPackages. Unlike withSerwistInit from @serwist/next, it takes no SW-related options.

3. Create the Route Handler

Create a Route Handler that builds and serves the SW.

// src/app/serwist/[path]/route.ts
import { spawnSync } from "node:child_process";
import { createSerwistRoute } from "@serwist/turbopack";
 
const revision =
  spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf-8" })
    .stdout.trim() || crypto.randomUUID();
 
export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } =
  createSerwistRoute({
    swSrc: "src/app/sw.ts",
    useNativeEsbuild: true,
    additionalPrecacheEntries: [
      { url: "/en/~offline", revision },
      { url: "/ja/~offline", revision },
    ],
  });

Key points:

  • swSrc specifies the SW source file
  • additionalPrecacheEntries explicitly precaches offline fallback pages
  • revision uses the git commit hash to bust the cache on each deploy
  • At build time, /serwist/sw.js and /serwist/sw.js.map are statically generated via SSG

4. Update sw.ts Imports

// Before
import { defaultCache } from "@serwist/next/worker";
 
// After
import { defaultCache } from "@serwist/turbopack/worker";

defaultCache provides the same recommended caching strategies for Next.js apps — the API is identical.

5. Register SW with SerwistProvider

@serwist/turbopack uses a SerwistProvider component for SW registration.

// src/components/pwa/serwist-provider.tsx
"use client";
export { SerwistProvider } from "@serwist/turbopack/react";
// src/app/[locale]/layout.tsx
import { SerwistProvider } from "@/components/pwa/serwist-provider";
 
export default async function LocaleLayout({ children, params }) {
  return (
    <html lang={locale}>
      <body>
        <SerwistProvider swUrl="/serwist/sw.js">
          <ThemeProvider>
            {children}
          </ThemeProvider>
        </SerwistProvider>
      </body>
    </html>
  );
}

Since SerwistProvider is a client component, it can't be imported directly from @serwist/turbopack/react in a Server Component layout. A "use client" re-export file is needed as an intermediary.

6. Update Middleware

Since the SW URL changes from /sw.js to /serwist/sw.js, update the middleware skip configuration.

// Before
const SKIP_PREFIXES = [..., "/sw.js", ...];
 
// After
const SKIP_PREFIXES = [..., "/serwist/", ...];

7. Cleanup

  • Delete scripts/build-sw.mjs (no longer needed)
  • Delete public/sw.js (now generated via Route Handler)
  • Remove public/sw* from .gitignore

8. Update Build Script

{
  "scripts": {
    "build": "velite build && next build --turbopack"
  }
}

With the esbuild script removed, the pipeline simplifies to just two steps: velite build → next build.

Results

Post-migration build output:

○ (serwist) Using esbuild to bundle the service worker.
✓ (serwist) 47 precache entries (1210.78 KiB)

● /serwist/[path]
  ├ /serwist/sw.js.map
  └ /serwist/sw.js
  • Precache manifest is now automatically injected — 47 entries (~1.2 MB) are precached
  • Build pipeline simplified from 3 stages (velite → esbuild → next) to 2 (velite → next)
  • Turbopack is now the default bundler, with an expected ~40% build time reduction compared to webpack

Conclusion

Migrating to @serwist/turbopack was a relatively small code change that delivered two significant improvements: a simpler build pipeline and a fully functional precache. If you're running a Next.js project with Serwist and want to switch to Turbopack, this guide should help you get started.

Share this post