Skip to main content

Search

Search problems, hooks, and pages

Back to About

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

SerwistProvider is disabled in development. Its install-time route warm-up requests every route, and under Turbopack each of those triggers a fresh on-demand compile — so a single "download for offline" run in dev would rebuild the entire app, one route at a time.

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