Installable & Offline-First
built with: PWA · Serwist service worker + Cache Storage API
A service worker compiled through a Route Handler, plus a hand-rolled download queue that warms routes into Cache Storage and can tell you what is already saved.
The problem: Turbopack has no plugin hook
`@serwist/next` compiles the service worker with a webpack plugin. This app builds with Turbopack, which has no equivalent hook — so the usual integration simply cannot run.
The workaround is to compile the worker on request from a Route Handler instead. `@serwist/turbopack` exposes `createSerwistRoute`, which takes the worker source and returns the whole set of route exports. The worker ends up served at `/serwist/sw.js` like any other route.
Two payloads get special handling. `/~offline` is the fallback page, revisioned by the Vercel commit SHA so a new deploy invalidates it. The self-hosted PDFium WASM binary is ~4.6MB — over Serwist's default file-size ceiling, where it would be silently warn-skipped by the auto-glob — so it is excluded from the glob and precached explicitly, hashed so it re-downloads only when the binary actually changes.
// src/app/serwist/[path]/route.ts
export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute({
swSrc: 'src/app/sw.ts',
// esbuild-wasm rejects Windows-style paths ("cwd is not an absolute path"); native esbuild doesn't.
useNativeEsbuild: true,
additionalPrecacheEntries: [
{ url: '/~offline', revision: process.env.VERCEL_GIT_COMMIT_SHA ?? Date.now().toString() },
{ url: '/pdfium.wasm', revision: pdfiumWasmRevision() }
],
globIgnores: ['**/pdfium.wasm']
});Warming a route takes two requests, not one
Downloading a section means fetching its routes so the service worker stores them. The catch is that a Next.js App Router page is two different resources: the HTML document you get on a hard load, and the RSC payload you get when a client-side `<Link>` navigates to it.
Cache only the document and in-app navigation still fails offline. So each URL is warmed twice, in parallel, and either one succeeding counts as cached.
The `Accept: text/html` header is load-bearing. A bare `fetch()` sends `Accept: */*`, which matches none of the service worker's page-matcher conditions — the request sails straight past the cache and nothing gets stored, with no error to tell you.
// src/hooks/useOfflineDownload.ts
async function warmUrl(url: string): Promise<boolean> {
const rscUrl = `${url}${url.includes('?') ? '&' : '?'}_rsc=offline`;
// Accept: text/html is required — a plain fetch() sends `Accept: */*`, which matches none of
// the SW's offlinePages matcher conditions, silently skipping the cache entirely.
const docReq = fetch(url, { cache: 'reload', credentials: 'same-origin', headers: { Accept: 'text/html' } })
.then((res) => res.text().then(() => res.ok).catch(() => res.ok))
.catch(() => false);
const rscReq = fetch(rscUrl, { cache: 'reload', credentials: 'same-origin', headers: { RSC: '1' } })
.then((res) => res.text().then(() => res.ok).catch(() => res.ok))
.catch(() => false);
const [docOk, rscOk] = await Promise.all([docReq, rscReq]);
return docOk || rscOk;
}Detecting what is already saved
The obvious way to ask "is this cached?" is `caches.match(url)`. That produced false negatives constantly: stored request keys carry RSC headers and vary data that a plain string match cannot reproduce, so entries the worker would happily serve reported as missing.
The fix is to stop matching and start enumerating — walk every cache, collect every stored pathname into a `Set`, and compare by pathname. That is ground truth. The enumeration is memoised for 1.5s so a burst of probes (the panel checking all sections at once when it opens) shares one pass, with an explicit invalidator to call after a download changes the caches.
// src/utils/offline-cache.ts
export async function getCachedPathnames(): Promise<Set<string>> {
if (!cachedPathnamesPromise) {
cachedPathnamesPromise = readCachedPathnames();
cachedPathnamesPromise.finally(() => {
setTimeout(() => {
cachedPathnamesPromise = null;
}, 1500);
});
}
return cachedPathnamesPromise;
}
/** Force the next probe to re-enumerate (call after warming/downloading routes). */
export function refreshCachedPathnames(): void {
cachedPathnamesPromise = null;
}The queue, and knowing when a build goes stale
Downloads run through a FIFO queue draining one section at a time, guarded by a ref so overlapping renders cannot start two runs. The `core` section — the app shell every other route depends on — is always sorted to the front and auto-prepended if you pick any other section without it.
Staleness is tracked by asking the service worker for its version over a `MessageChannel` and comparing it against a marker in `localStorage` written after each completed section. A newer worker than the marker means a fresh deploy shipped since you last downloaded, which flips every saved section to a `stale` badge and prompts a re-download.
// src/hooks/useOfflineDownload.ts
function getServiceWorkerVersion(timeoutMs = 1500): Promise<string | null> {
return new Promise((resolve) => {
const sw = navigator.serviceWorker?.controller;
if (!sw) return resolve(null);
const channel = new MessageChannel();
const timer = setTimeout(() => resolve(null), timeoutMs);
channel.port1.onmessage = (e) => {
clearTimeout(timer);
resolve(e.data?.type === 'VERSION' ? (e.data.version as string) : null);
};
sw.postMessage({ type: 'GET_VERSION' }, [channel.port2]);
});
}Important
Gotcha
Where it lives in the repo
- src/app/serwist/[path]/route.ts
- src/app/sw.ts
- src/hooks/useOfflineDownload.ts
- src/utils/offline-cache.ts
- src/data/offline-content.ts
- src/components/pwa/offline-download-panel.tsx
- src/app/providers.tsx
- next.config.ts