Migrating Next.js PWA to @serwist/turbopack for Faster Builds
Table of Contents
The Problem
If you've ever bolted PWA support onto a Next.js site, you know the pain: the Service Worker tooling always feels like it's fighting the framework. My setup was a perfect example. I had @serwist/next (a webpack plugin) combined with a custom esbuild script to bundle the SW, and it was broken in ways I'd been ignoring for too long.
The esbuild script never injected a precache manifest, which meant precaching — arguably the whole point of a PWA — wasn't actually working. The build pipeline had ballooned to three stages (velite build → esbuild → next build), and worst of all, I couldn't switch to Turbopack because @serwist/next was hardwired to webpack.
When I saw that @serwist/turbopack took a fundamentally different approach — using a Next.js Route Handler instead of a bundler plugin — I was curious enough to try it.
Why the Route Handler Approach is Clever
Here's what surprised me most about @serwist/turbopack: it doesn't hook into the bundler at all. Instead, it compiles the Service Worker through a regular Next.js Route Handler that runs esbuild at build time and outputs static files via SSG. The precache manifest gets injected as part of that process.
By treating the SW as "just another route," it sidesteps the entire webpack-vs-Turbopack question. It also means the SW build participates in Next.js's own static generation, so you get source maps, proper caching headers, and all the other niceties for free. This is an elegant design choice.
| Aspect | @serwist/next (webpack) | @serwist/turbopack |
|---|---|---|
| SW build | Webpack plugin auto-generates | Route Handler via esbuild |
| SW URL | /sw.js (output to public/) | /serwist/sw.js (Route Handler path) |
| Precache manifest | Injected by webpack plugin | Injected by createSerwistRoute |
| SW registration | Automatic via register option | Explicit via SerwistProvider component |
| Turbopack support | No | Native |
Migration
Dependencies and Config
First, swap the packages and wrap your Next.js config with withSerwist. Unlike withSerwistInit from the old package, this wrapper is dead simple — all it does is add esbuild to serverExternalPackages. No SW options, no magic.
npm uninstall @serwist/next
npm install -D @serwist/turbopack esbuildimport { withSerwist } from "@serwist/turbopack";
export default withSerwist({
// ...existing config
});The Route Handler (The Interesting Part)
This is the core of the new approach. A single Route Handler replaces both the webpack plugin and my custom esbuild script:
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 },
],
});I like that createSerwistRoute returns all the standard Next.js Route Handler exports — generateStaticParams, GET, etc. — so the SW gets built at next build time as a regular static route. Using the git commit hash as revision means the precache busts automatically on every deploy without any extra CI logic.
SerwistProvider for Registration
One trade-off of the new approach: SW registration is no longer automatic. You need a SerwistProvider component. Since it's a client component, you can't import it directly in a Server Component layout — a small "use client" re-export file bridges the gap:
"use client";
export { SerwistProvider } from "@serwist/turbopack/react";Then wrap your layout with it, pointing swUrl to the Route Handler path (/serwist/sw.js).
Cleanup
The remaining changes are straightforward: update the import in sw.ts from @serwist/next/worker to @serwist/turbopack/worker, update middleware to skip /serwist/ instead of /sw.js, and delete the old scripts/build-sw.mjs and public/sw.js. With the esbuild script gone, the build simplifies to velite build && next build --turbopack.
Results
The build output after migration:
○ (serwist) Using esbuild to bundle the service worker.
✓ (serwist) 47 precache entries (1210.78 KiB)
● /serwist/[path]
├ /serwist/sw.js.map
└ /serwist/sw.js
47 precache entries, automatically discovered and injected. Previously this number was zero. That alone made the migration worth it.
Takeaways
- Fight the framework less. The Route Handler approach works because it uses Next.js's own primitives instead of fighting the build system. When evaluating tools, look for designs that work with the framework rather than around it.
- Broken features hide in plain sight. Precaching had been silently non-functional for weeks. If something is supposed to work but you've never verified it, it probably doesn't. Check the actual build output.
- Explicit beats automatic. Having to add
SerwistProvidermanually felt like a downgrade at first, but it's actually clearer — I can see exactly where and how the SW gets registered, which makes debugging easier.
