Skip to content

Framework integrations for progressive web apps

A framework integration connects a PWA's four moving parts to a framework's build and router: the web app manifest, a service worker whose precache list matches the hashed build output, the registration code, and an update flow that doesn't break a running single-page app. Some frameworks ship this themselves: Angular has @angular/service-worker, SvelteKit has the $service-worker module, and Next.js has a manifest file convention. Others depend on vite-plugin-pwa wrappers, Serwist, or a Workbox step that you add after the build. This page covers each option as of September 2026, with complete configuration, the URLs each router fetches behind your back, and the failure modes that are specific to each framework.

Key takeaways

  • Angular is the only major framework with a first-party, batteries-included service worker. ng add @angular/pwa wires up ngsw-worker.js, which you drive with ngsw-config.json, and the SwUpdate and SwPush services. It uses its own hash-table versioning, not Workbox.
  • Next.js 16 builds with Turbopack by default. @serwist/next hooks into webpack, so it needs next build --webpack. On Turbopack, use @serwist/turbopack, which serves the worker from a route handler, or Serwist's "configurator" mode. The official Next.js PWA guide covers the manifest and push, and explicitly leaves offline caching to Serwist.
  • SvelteKit bundles and auto-registers src/service-worker.{js,ts}. It exposes build, files, prerendered, version and base through $service-worker, and that is enough for a correct precaching worker without Workbox.
  • Nuxt, Astro and VitePress use the @vite-pwa/* wrappers around vite-plugin-pwa. @vite-pwa/astro 1.2.0 still declares Astro peers only up to 5.x, while Astro 7 is current, so plan for an override or a post-build Workbox step.
  • React Router 8, SolidStart 2 and Qwik have no maintained first-party PWA package. A post-build workbox-build injectManifest step over the client output directory is the most robust choice, provided your worker understands the router's data URLs (.data, /__manifest, _payload.json, RSC requests).
  • Create React App was deprecated on 14 February 2025, and Gatsby's gatsby-plugin-offline still depends on Workbox 4 (2019). Treat both as migration targets. Ship a kill-switch worker when you remove them.

Versions covered on this page

The table lists the latest versions on the npm registry on 25 September 2026. Framework release cadences are fast, so check npm view <package> version before you copy any configuration.

Framework / package Version Published PWA mechanism
@angular/service-worker, @angular/pwa 22.2.0 2026-09-23 First-party ngsw-worker.js
next 16.3.6 2026-09-22 app/manifest.ts; worker via Serwist or hand-written
serwist, @serwist/next, @serwist/turbopack 9.5.12 2026-07-22 Workbox-derived library; injectManifest only
nuxt / @vite-pwa/nuxt 4.5.2 / 1.1.1 2026-08-05 / 2026-02-06 vite-plugin-pwa Nuxt module
@sveltejs/kit 2.70.3 2026-08-18 Built-in src/service-worker + $service-worker
react-router, @react-router/dev 8.4.0 2026-09-15 None built in; framework mode on Vite 7 or 8
astro / @vite-pwa/astro 7.3.5 / 1.2.0 2026-09-24 / 2025-11-27 vite-plugin-pwa integration (peer range stops at Astro 5)
vue 3.5.43 2026-09-17 vite-plugin-pwa (virtual:pwa-register/vue)
@solidjs/start 2.0.5 2026-09-10 None built in; Vite 8 + Nitro 3
@builder.io/qwik (Qwik City 1.x) 1.20.1 2026-09-23 src/routes/service-worker.ts entry; prefetch worker deprecated
react-scripts / cra-template-pwa 5.0.1 / 2.0.0 2022-04-12 / 2022-05-06 Legacy; Workbox 6 InjectManifest
gatsby / gatsby-plugin-offline 5.16.1 / 6.16.0 2026-02-10 / 2026-01-26 Legacy; workbox-build ^4.3.1

The mechanics underneath every row are the same. Workbox fundamentals covers generateSW and injectManifest, and Precaching explains revisioned precache manifests. The Vite PWA plugin page documents vite-plugin-pwa itself. This page focuses on what each framework changes.

The five problems every integration solves

Regardless of framework, a PWA integration has to answer five questions. When an integration fails, it's almost always because one of these answers is wrong for your deployment mode (static, SSR, hybrid or edge).

  1. Where does the manifest come from, and is it linked on every page? SSR frameworks render <head> per route, so the <link rel="manifest"> has to go into the root layout, not into a static index.html. See the manifest members reference.
  2. Which files are precached, and how are they revisioned? Hashed assets (/_next/static/…, /_app/immutable/…, /assets/…, /_astro/…) can be precached without a revision. Unhashed files (/, /offline, icons) need a content hash. The glob has to target the client output and never the server bundle.
  3. What answers a navigation request offline? A single-page app can serve one cached shell for every route (navigateFallback). An SSR or multi-page app must not, because the cached shell doesn't contain the route's HTML. It needs network-first navigations with an offline page. See SPA vs MPA PWAs.
  4. What does the router fetch besides HTML? Client-side routers fetch data and code through framework-specific URLs: React Server Component payloads, __data.json, .data, _payload.json and /__manifest. A worker that doesn't know about them either caches personalized data or breaks offline navigation.
  5. How is a new version activated? Every framework lazy-loads route chunks, so a page running version N that suddenly gets version N+1's worker can request chunks that no longer exist. The integration must prompt, or it must keep old chunks available. See Updating service workers.
flowchart TD
    A["Which framework?"] --> B{"First-party worker?"}
    B -->|"Angular"| C["ng add @angular/pwa<br/>ngsw-config.json"]
    B -->|"SvelteKit"| D["src/service-worker.ts<br/>$service-worker"]
    B -->|"No"| E{"Built on Vite with an @vite-pwa wrapper?"}
    E -->|"Nuxt, Astro 5, VitePress"| F["@vite-pwa/nuxt, @vite-pwa/astro, ..."]
    E -->|"Plain Vite + React/Vue/Svelte/Solid"| G["vite-plugin-pwa"]
    E -->|"No"| H{"Next.js?"}
    H -->|"Turbopack build"| I["@serwist/turbopack or configurator mode"]
    H -->|"webpack build"| J["@serwist/next"]
    H -->|"React Router 8, SolidStart 2, Qwik, Astro 6+"| K["Post-build workbox-build injectManifest"]

Angular: @angular/service-worker

Angular's service worker (ngsw-worker.js) is a complete, versioned caching engine that the Angular team ships with every release. Unlike Workbox, you don't write worker code. The build generates ngsw.json, a manifest of every file with its SHA-1 hash, from your ngsw-config.json, and the prebuilt worker interprets it at runtime. @angular/service-worker and @angular/pwa share Angular's version number: 22.2.0 in September 2026.

What ng add @angular/pwa changes

Terminal
ng add @angular/pwa --project my-app

The schematic in @angular/pwa 22.2.0 does the following. It's worth knowing exactly, because the defaults need editing.

Change Detail
Adds @angular/service-worker Dependency at the same version as @angular/core
Build configuration For the application builder, sets "serviceWorker": "ngsw-config.json" on the production configuration only. If no production configuration exists, it logs a warning and you set it by hand. Older browser builders get serviceWorker: true plus ngswConfigPath.
Registration Adds provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000' }) to the root providers in app.config.ts
index.html Inserts <link rel="manifest" href="manifest.webmanifest"> before </head>, and a <noscript> message if the page has none. It doesn't add a theme-color meta tag; add one yourself.
manifest.webmanifest name and short_name set to the project title, display: "standalone", scope: "./", start_url: "./", and eight PNG icons from 72×72 to 512×512
ngsw-config.json Two asset groups, described below

Two defaults need fixing before you ship. Every generated icon uses "purpose": "maskable any". Chrome DevTools flags "any maskable" as discouraged, because one image can't have the right padding for both uses: an icon padded for masking looks too small when shown unmasked, and an unpadded one gets cropped. Supply separate any and maskable images as described in Icons & maskable icons. The manifest also has no id, theme_color or background_color. Add them, and pin id before your first release, as App identity & updates explains.

ngsw-config.json in depth

This is the generated configuration from @schematics/angular 22.2.0:

ngsw-config.json (generated)
{
  "$schema": "./node_modules/@angular/service-worker/config/schema.json",
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": [
          "/favicon.ico",
          "/index.csr.html",
          "/index.html",
          "/manifest.webmanifest",
          "/*.css",
          "/*.js"
        ]
      }
    },
    {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": [
          "/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
        ]
      }
    }
  ]
}

The top-level keys, from the JSON schema shipped in @angular/service-worker/config/schema.json:

Key Type Default Meaning
index string required The file served for navigation requests, normally /index.html
appData object none Arbitrary data copied into ngsw.json. It reaches the page in VersionReadyEvent.currentVersion.appData and latestVersion.appData. Use it for release notes or a "critical update" flag.
assetGroups array none Versioned resources that update together with the app
dataGroups array none Unversioned runtime caching for API responses
navigationUrls glob[] ["/**", "!/**/*.*", "!/**/*__*", "!/**/*__*/**"] Which navigations are answered with index. The default excludes any URL with a file extension or a double underscore.
navigationRequestStrategy "performance" | "freshness" "performance" performance serves the cached index. freshness goes to the network and falls back to the cached index offline.
applicationMaxAge duration none A maximum age for the whole cached version. Beyond it, the worker bypasses its caches and serves from the network.

Asset groups are checked in order, so put specific groups first. installMode: "prefetch" downloads every matching file when a version is installed. "lazy" caches a file only when it's first requested. updateMode defaults to the value of installMode. With "lazy", a file that changed between versions is dropped and re-fetched on demand, and with "prefetch", it's downloaded eagerly. resources.files globs match files in the build output, and each file gets a hash in ngsw.json. resources.urls matches URLs at runtime, such as a font CDN. Those responses aren't hashed, so they follow HTTP caching headers. cacheQueryOptions.ignoreSearch (default false) ignores query strings when matching.

Data groups configure runtime caching for API calls. Only GET and HEAD requests are cached.

ngsw-config.json (dataGroups excerpt)
{
  "dataGroups": [
    {
      "name": "api-profile",
      "urls": ["/api/me", "/api/settings"],
      "version": 2,
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 10,
        "maxAge": "1d",
        "timeout": "3s"
      }
    },
    {
      "name": "api-catalog",
      "urls": ["/api/products/**", "https://cdn.example.com/catalog/**"],
      "cacheConfig": {
        "strategy": "performance",
        "maxSize": 200,
        "maxAge": "6h",
        "refreshAhead": "30m",
        "cacheOpaqueResponses": false
      }
    }
  ]
}
cacheConfig field Meaning
strategy "performance" (default) is cache-first within maxAge. "freshness" is network-first with timeout, falling back to the cache.
maxSize Maximum number of entries, not bytes. The oldest entries are evicted beyond it.
maxAge How long an entry is valid
timeout Network timeout for freshness, after which the cached response is used
refreshAhead How long before expiry to proactively refresh an entry in the background
cacheOpaqueResponses Whether to store no-CORS responses. It defaults to true for freshness and false for performance.
version (on the group) An integer that defaults to 1. Bumping it discards the group's cache, for example when the API's response format changes incompatibly.

Durations use the suffixes d, h, m, s and u (milliseconds), so "3d12h" means three and a half days and "5s30u" means 5.03 seconds. The globs are a limited format that is converted to a regular expression. ** matches zero or more path segments, * matches characters except /, ? matches exactly one character except /, and a leading ! negates the pattern. Characters such as $ are not escaped automatically. Write \\$ in the JSON.

How ngsw versioning and updates work

The worker treats ngsw.json as the identity of an app version: its hash is the version hash. The reading of ngsw-worker.js in 22.2.0 below explains the behavior you observe in DevTools:

  • Immediate activation, deferred version switching. The worker calls skipWaiting() on install and clients.claim() on activate. Browser-level lifecycle rarely matters, because the worker keeps several app versions side by side and pins each client (tab) to the version it loaded with. New tabs get the latest version, and existing tabs stay on theirs until they reload or call activateUpdate().
  • Update checks. The first navigation request after the worker starts schedules a check-updates-on-navigation task. The worker fetches ngsw.json?ngsw-cache-bust=<random>, and if the hash differs, it downloads the new version's prefetch groups in the background and emits VERSION_DETECTED, then VERSION_READY or VERSION_INSTALLATION_FAILED.
  • Idle scheduling. Update checks and cache cleanup run through an idle scheduler with a 5-second idle delay and a 30-second maximum delay. That's why VERSION_READY often arrives several seconds after page load.
  • Integrity. Each file is verified against its hash in ngsw.json. On a mismatch, which usually means a CDN served stale content, the worker re-fetches the file with a cache-busting parameter. If that also mismatches, it treats the whole version as broken.
  • Driver states. NORMAL is the healthy state. EXISTING_CLIENTS_ONLY means the worker has no clean copy of the latest version: existing tabs keep running from cache, and new loads go to the network. SAFE_MODE means the worker can't trust its caches and serves everything from the network.
  • Unhashed content. Resources matched by urls patterns are served stale-while-revalidate based on their HTTP caching headers.
sequenceDiagram
    participant Tab as Tab (version A)
    participant SW as ngsw-worker.js
    participant Srv as Server
    Tab->>SW: navigation request
    SW-->>Tab: index.html from version A cache
    Note over SW: idle task: check-updates-on-navigation
    SW->>Srv: GET ngsw.json?ngsw-cache-bust=0.83...
    Srv-->>SW: manifest, hash B
    SW-->>Tab: VERSION_DETECTED (B)
    SW->>Srv: prefetch changed files of B
    SW-->>Tab: VERSION_READY (current A, latest B)
    Tab->>SW: ACTIVATE_UPDATE (after user confirms)
    SW-->>Tab: client pinned to B
    Tab->>Tab: document.location.reload()

Registration options

provideServiceWorker(script, options) takes a SwRegistrationOptions object:

Option Default Notes
enabled true false also disables SwUpdate and SwPush (their isEnabled becomes false). The schematic sets !isDevMode().
scope worker's directory Passed to navigator.serviceWorker.register()
registrationStrategy 'registerWhenStable:30000' registerWhenStable:<ms>, registerImmediately, registerWithDelay:<ms>, or a function returning an Observable whose first emission triggers registration
updateViaCache browser default ('imports') See updateViaCache
type 'classic' 'module' only matters for a custom worker that uses import

registerWhenStable:30000 races ApplicationRef.whenStable() against a 30-second timer, so an app that never stabilizes still registers after 30 seconds. The purpose is to keep the worker's installation, which downloads every prefetch file, from competing with the app's own startup requests. registerImmediately is appropriate only if your initial bundle is small. The provider also posts an INITIALIZE message to the controller on every controllerchange, which is how a newly claimed worker learns about the tab.

SwUpdate: prompting for new versions

SwUpdate.versionUpdates emits four event types in 22.2.0: VERSION_DETECTED, VERSION_READY (with currentVersion and latestVersion, each a { hash, appData }), VERSION_INSTALLATION_FAILED (with version and an error string) and NO_NEW_VERSION_DETECTED. The separate unrecoverable observable emits UNRECOVERABLE_STATE with a reason. checkForUpdate() and activateUpdate() both return Promise<boolean>, and both reject if the worker isn't enabled.

The service below prompts once per version, checks for updates every 30 minutes and when the tab becomes visible, and reloads on unrecoverable state:

src/app/pwa/update.service.ts
import { ApplicationRef, DestroyRef, Injectable, inject, signal } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter, interval } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

const CHECK_INTERVAL_MS = 30 * 60 * 1000;

@Injectable({ providedIn: 'root' })
export class UpdateService {
  private readonly updates = inject(SwUpdate);
  private readonly appRef = inject(ApplicationRef);
  private readonly destroyRef = inject(DestroyRef);

  /** The version the UI should offer, or null when nothing is waiting. */
  readonly pending = signal<VersionReadyEvent | null>(null);

  init(): void {
    if (!this.updates.isEnabled) return; // dev mode, unsupported browser, or enabled: false

    this.updates.versionUpdates
      .pipe(
        filter((e): e is VersionReadyEvent => e.type === 'VERSION_READY'),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe((event) => this.pending.set(event));

    this.updates.versionUpdates
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((event) => {
        if (event.type === 'VERSION_INSTALLATION_FAILED') {
          // Usually a CDN serving a file whose hash doesn't match ngsw.json.
          console.error(`ngsw: installing ${event.version.hash} failed: ${event.error}`);
        }
      });

    // A broken cached version can't be recovered in place. Reload onto the network.
    this.updates.unrecoverable
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(({ reason }) => {
        console.error('ngsw unrecoverable state:', reason);
        document.location.reload();
      });

    // Wait for stability so polling doesn't delay registration (registerWhenStable).
    this.appRef.whenStable().then(() => {
      interval(CHECK_INTERVAL_MS)
        .pipe(takeUntilDestroyed(this.destroyRef))
        .subscribe(() => this.check());
      document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'visible') this.check();
      });
    });
  }

  private async check(): Promise<void> {
    try {
      await this.updates.checkForUpdate(); // resolves true when a new version is ready
    } catch (err) {
      console.warn('ngsw update check failed', err); // offline, or worker not yet ready
    }
  }

  /** Call from the "Reload to update" button. */
  async apply(): Promise<void> {
    try {
      await this.updates.activateUpdate();
    } finally {
      // Always reload: the running bundle belongs to the old version, and its
      // lazy chunks may not exist in the new version's cache.
      document.location.reload();
    }
  }
}

Call inject(UpdateService).init() from the root component's constructor, and render a banner when pending() is non-null. Never call activateUpdate() without reloading. Angular's documentation warns that swapping the version under a running app "could break the application", because lazy-loaded chunks are then served from a version the shell wasn't built with. Update patterns compares this prompt-and-reload design with the alternatives.

SwPush: Web Push through ngsw

ngsw-worker.js has built-in push, notificationclick, notificationclose and pushsubscriptionchange handlers. SwPush exposes them as observables:

Member Type Purpose
requestSubscription({ serverPublicKey }) Promise<PushSubscription> Calls pushManager.subscribe() with userVisibleOnly: true and your VAPID key, which prompts for permission if it isn't granted yet
unsubscribe() Promise<void> Unsubscribes the current subscription
subscription Observable<PushSubscription \| null> The current subscription
messages Observable<object> Every push payload (the worker broadcasts a PUSH message to all clients)
notificationClicks Observable<{ action, notification }> Clicks, including the action ID
notificationCloses Observable<{ action, notification }> Dismissals
pushSubscriptionChanges Observable<{ oldSubscription, newSubscription }> Browser-initiated subscription rotation

The worker shows a notification only if the payload is JSON with a notification object containing a title. Every other payload is just broadcast to open tabs. What happens on click is controlled by notification.data.onActionClick, keyed by action ID, with default covering a click on the notification body:

operation Behavior (from ngsw-worker.js)
openWindow clients.openWindow(url)
focusLastFocusedOrOpen Focus the most recently focused client in scope, or open url
navigateLastFocusedOrOpen Navigate the most recently focused client to url and focus it, or open url
sendRequest fetch(url) from the worker, for example to acknowledge a message without opening the app

URLs are resolved against the registration scope. Without an onActionClick entry, the notification is closed and only notificationClicks fires.

server/send-push.ts
import webpush, { type PushSubscription } from 'web-push';

webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!,
);

export async function notifyNewMessage(sub: PushSubscription, threadId: string) {
  const payload = {
    notification: {
      title: 'New message',
      body: 'Ana replied to your comment',
      icon: '/icons/icon-192.png',
      tag: `thread-${threadId}`, // collapses repeated notifications
      actions: [{ action: 'mark-read', title: 'Mark as read' }],
      data: {
        onActionClick: {
          default: { operation: 'navigateLastFocusedOrOpen', url: `/threads/${threadId}` },
          'mark-read': { operation: 'sendRequest', url: `/api/threads/${threadId}/read` },
        },
      },
    },
  };
  try {
    await webpush.sendNotification(sub, JSON.stringify(payload), { TTL: 3600, urgency: 'normal' });
  } catch (err: any) {
    // 404/410 mean the subscription is gone: delete it from your database.
    if (err.statusCode === 404 || err.statusCode === 410) return 'expired';
    throw err;
  }
  return 'sent';
}
src/app/pwa/push-toggle.component.ts
import { Component, inject, signal } from '@angular/core';
import { SwPush } from '@angular/service-worker';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../environments/environment';

@Component({
  selector: 'app-push-toggle',
  template: `
    @if (supported) {
      <button type="button" (click)="toggle()" [disabled]="busy()">
        {{ subscribed() ? 'Turn off notifications' : 'Turn on notifications' }}
      </button>
    }
  `,
})
export class PushToggleComponent {
  private readonly push = inject(SwPush);
  private readonly http = inject(HttpClient);
  readonly supported = this.push.isEnabled && 'PushManager' in window;
  readonly subscribed = signal(false);
  readonly busy = signal(false);

  constructor() {
    this.push.subscription.subscribe((s) => this.subscribed.set(!!s));
    // Keep the server in sync when the browser rotates the subscription.
    this.push.pushSubscriptionChanges.subscribe(({ newSubscription }) => {
      if (newSubscription) this.http.post('/api/push/subscriptions', newSubscription).subscribe();
    });
  }

  async toggle(): Promise<void> {
    this.busy.set(true);
    try {
      if (this.subscribed()) {
        await this.push.unsubscribe();
        await firstValueFrom(this.http.delete('/api/push/subscriptions/current'));
      } else {
        // Must run inside the click handler: Safari requires a user gesture.
        const sub = await this.push.requestSubscription({ serverPublicKey: environment.vapidPublicKey });
        await firstValueFrom(this.http.post('/api/push/subscriptions', sub.toJSON()));
      }
    } catch (err) {
      console.error('Push subscription failed', err); // permission denied, or no worker
    } finally {
      this.busy.set(false);
    }
  }
}

The protocol, VAPID and payload encryption are covered on Push notifications and The Web Push protocol. iOS and iPadOS deliver push only to Home Screen web apps. See Web Push on iOS & Safari.

Adding your own worker code

ngsw-worker.js has no plugin API. The supported way to add behavior, such as a periodicsync handler or a custom notificationclick, is a wrapper script that imports it:

public/custom-sw.js
// Load Angular's worker first so its fetch handler is registered first.
importScripts('./ngsw-worker.js');

// Runs in addition to ngsw's listeners. Don't call event.respondWith() for
// requests ngsw handles, or the browser throws InvalidStateError.
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'refresh-inbox') {
    event.waitUntil(
      fetch('/api/inbox?prefetch=1', { credentials: 'include' })
        .then((res) => (res.ok ? caches.open('inbox-prefetch').then((c) => c.put('/api/inbox', res)) : undefined))
        .catch(() => undefined), // offline: try again at the next sync
    );
  }
});

Register 'custom-sw.js' instead of 'ngsw-worker.js' in provideServiceWorker(). Files in public/ are copied to the output root in current Angular projects. The build still generates ngsw-worker.js and ngsw.json, and the wrapper imports the worker at runtime. Because importScripts() URLs are part of the update check, a new ngsw-worker.js still updates the registration. See imported scripts.

Angular SSR and the service worker

The generated asset group lists both /index.html and /index.csr.html, so the same configuration works whether the build emits a client-only index or the CSR fallback that @angular/ssr produces. With the default navigationRequestStrategy: "performance", the worker answers every matching navigation with the cached index, so server rendering is bypassed for returning visitors. The app boots client-side, and the server-rendered HTML is never seen. If SSR matters (for example, personalized first paint or Link headers), set "navigationRequestStrategy": "freshness", so navigations go to the network and fall back to the cached index only when offline. For routes that must never get the SPA shell, such as /api/** redirects or server-rendered admin pages, exclude them in navigationUrls with ! patterns.

Debugging and switching ngsw off

  • /ngsw/state: requesting this path under the scope returns a plain-text report with the driver state (NORMAL, EXISTING_CLIENTS_ONLY or SAFE_MODE), the latest manifest hash, the last update check, every cached version and the clients pinned to it, the idle task queue, and a debug log. It's the first thing to look at when users report stale content.
  • Bypass: a request with an ngsw-bypass header or query parameter (any value, including empty) is not intercepted. Use it for requests that must never touch the cache, such as file uploads with progress events. Note that a custom header makes a cross-origin request preflighted.
  • Fail-safe: if ngsw.json returns 404, the worker deletes all its caches and unregisters itself. Deleting ngsw.json from the deployment is the fastest emergency switch.
  • safety-worker.js: the package ships a worker that unregisters itself and reloads its clients. If you stop using ngsw, serve the safety worker at the old worker URL (ngsw-worker.js), and keep serving it there indefinitely, because some users return after months.

Next.js

Next.js has no built-in service worker. It provides a manifest file convention and an official PWA guide covering the manifest, push and security headers. For offline caching, the guide points to Serwist, a TypeScript-first, ESM-only fork of Workbox. The deciding fact in 2026 is the bundler: since Next.js 16 (21 October 2025), next dev and next build use Turbopack by default, and webpack-based PWA plugins only run with --webpack.

The manifest: app/manifest.ts

Put manifest.json, manifest.webmanifest, manifest.js or manifest.ts in the root of app/. A manifest.ts default export returning MetadataRoute.Manifest becomes a route handler at /manifest.webmanifest (Next.js normalizes the /manifest metadata route to that filename), and Next.js adds the <link rel="manifest"> to every page. The handler is cached (static) by default unless it uses a request-time API such as headers() or cookies().

app/manifest.ts
import type { MetadataRoute } from 'next';

export default function manifest(): MetadataRoute.Manifest {
  return {
    id: '/',                      // pin the identity before the first release
    name: 'Acme Tasks',
    short_name: 'Tasks',
    description: 'Plan, track and finish work offline.',
    start_url: '/?source=pwa',
    scope: '/',
    display: 'standalone',
    display_override: ['window-controls-overlay', 'standalone'],
    background_color: '#ffffff',
    theme_color: '#1e3a8a',
    icons: [
      { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
      { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
      { src: '/icons/maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
    ],
    screenshots: [
      { src: '/screens/wide.png', sizes: '1280x800', type: 'image/png', form_factor: 'wide' },
      { src: '/screens/narrow.png', sizes: '750x1334', type: 'image/png', form_factor: 'narrow' },
    ],
    shortcuts: [{ name: 'New task', url: '/tasks/new', icons: [{ src: '/icons/new-96.png', sizes: '96x96' }] }],
  };
}

The MetadataRoute.Manifest type follows web standards and can lag behind the newest members (launch_handler, scope_extensions and others). If TypeScript rejects a member you need, serve a static app/manifest.webmanifest instead. Theme color and Apple-specific tags come from the viewport and metadata exports of the root layout (themeColor, appleWebApp), not from the manifest.

Web Push following the Next.js guide

The official guide (last updated 30 July 2026, for Next.js 16.3.6) builds push notifications with Server Actions and the web-push package. Its design is sound and follows this site's recommendations: check 'serviceWorker' in navigator && 'PushManager' in window, register with updateViaCache: 'none', subscribe with userVisibleOnly: true and the VAPID public key from NEXT_PUBLIC_VAPID_PUBLIC_KEY, and serialize the subscription before passing it to a Server Action. It also generates keys with web-push generate-vapid-keys, tests locally with next dev --experimental-https, and serves the worker with Cache-Control: no-cache, no-store, must-revalidate and a strict Content-Security-Policy.

Three things need changing before the guide's code is production-ready:

  1. Store subscriptions. The guide keeps a single subscription in a module-level variable, so a server restart or a second user loses it. Persist each subscription keyed by user and endpoint, and delete it when the push service answers 404 or 410.
  2. Validate Server Action input. A Server Action is a public HTTP endpoint. Check that the endpoint URL is https:, that it belongs to a known push service if you want to be strict, and that the keys have the expected base64url lengths.
  3. Don't rely on beforeinstallprompt detection. The guide's InstallPrompt shows iOS instructions by sniffing the user agent and checks display-mode: standalone. That's fine as a hint, but see Install prompts & custom UI for a cross-browser approach.
app/actions/push.ts
'use server';

import webpush from 'web-push';
import { z } from 'zod';
import { auth } from '@/lib/auth';          // your session helper
import { db } from '@/lib/db';              // your database client

webpush.setVapidDetails(
  'mailto:[email protected]',
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!,
);

const Subscription = z.object({
  endpoint: z.string().url().startsWith('https://'),
  expirationTime: z.number().nullable().optional(),
  keys: z.object({ p256dh: z.string().min(80).max(100), auth: z.string().min(16).max(30) }),
});

export async function saveSubscription(raw: unknown) {
  const user = await auth();
  if (!user) throw new Error('Not signed in');
  const sub = Subscription.parse(raw);
  await db.pushSubscription.upsert({
    where: { endpoint: sub.endpoint },
    create: { endpoint: sub.endpoint, p256dh: sub.keys.p256dh, auth: sub.keys.auth, userId: user.id },
    update: { p256dh: sub.keys.p256dh, auth: sub.keys.auth, userId: user.id },
  });
}

export async function notifyUser(userId: string, title: string, body: string, url: string) {
  const subs = await db.pushSubscription.findMany({ where: { userId } });
  await Promise.all(
    subs.map(async (s) => {
      try {
        await webpush.sendNotification(
          { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
          JSON.stringify({ title, body, url }),
          { TTL: 86400, urgency: 'normal' },
        );
      } catch (err: any) {
        if (err.statusCode === 404 || err.statusCode === 410) {
          await db.pushSubscription.delete({ where: { endpoint: s.endpoint } }); // expired
        } else {
          console.error('push failed', s.endpoint, err.statusCode);
        }
      }
    }),
  );
}

The push and notificationclick handlers go into the worker, either a plain public/sw.js or the Serwist worker below. The handler code is the same in any framework and is shown on Push notifications.

Offline with Serwist: choose by bundler

Serwist 9.5.12 offers three ways to build a worker for Next.js:

Mode Package Build How the worker is produced and served Registration
webpack plugin @serwist/next next build --webpack Child compilation during the webpack build writes public/sw.js Automatic (register: true) or through @serwist/window
Turbopack route @serwist/turbopack next build (Turbopack) A route handler at app/serwist/[path]/route.ts bundles app/sw.ts with esbuild and serves /serwist/sw.js (statically generated) <SerwistProvider swUrl="/serwist/sw.js">
Configurator @serwist/next/config + @serwist/cli next build && serwist build The CLI reads the build output after Next.js finishes and writes public/sw.js <SerwistProvider swUrl="/sw.js">

@serwist/next checks for process.env.TURBOPACK and prints a warning with these three alternatives. And because Next.js 16 fails a Turbopack production build when a webpack configuration is present, the webpack plugin is no longer a silent no-op. You either add --webpack or migrate.

@serwist/turbopack setup

Terminal
npm i -D @serwist/turbopack esbuild serwist
next.config.mjs
import { withSerwist } from '@serwist/turbopack';

// withSerwist only adds esbuild and esbuild-wasm to serverExternalPackages.
export default withSerwist({
  reactStrictMode: true,
});
app/serwist/[path]/route.ts
import { spawnSync } from 'node:child_process';
import { createSerwistRoute } from '@serwist/turbopack';

// Revision for URLs that aren't content-hashed, such as the offline page.
const revision =
  spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf-8' }).stdout?.trim() || crypto.randomUUID();

export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute({
  swSrc: 'app/sw.ts',
  useNativeEsbuild: true, // false falls back to esbuild-wasm
  additionalPrecacheEntries: [{ url: '/~offline', revision }],
});

createSerwistRoute() returns dynamic = "force-static", dynamicParams = false and revalidate = false. At build time, generateStaticParams() runs esbuild over swSrc (ESM output, sourcemap: true, minified in production, target derived from your browserslist), and injects the precache manifest by replacing self.__SW_MANIFEST. The worker and its source map are prerendered as static routes. The GET handler sets Content-Type and Service-Worker-Allowed: /, which is what lets a script at /serwist/sw.js control scope /. Without that header, the registration would fail with a SecurityError, because the script's directory (/serwist/) is narrower than the requested scope. In development, additionalPrecacheEntries is emptied, and the worker is rebuilt when the SHA-256 hash of app/sw.ts changes.

app/sw.ts
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { defaultCache } from '@serwist/turbopack/worker';
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist';
import { Serwist } from 'serwist';

declare global {
  interface WorkerGlobalScope extends SerwistGlobalConfig {
    __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
  }
}
declare const self: ServiceWorkerGlobalScope;

const serwist = new Serwist({
  precacheEntries: self.__SW_MANIFEST,
  skipWaiting: false,       // prompt-based updates; see "Update handling" below
  clientsClaim: true,
  navigationPreload: true,  // start navigation requests while the worker boots
  runtimeCaching: defaultCache,
  fallbacks: {
    entries: [
      {
        url: '/~offline',
        matcher: ({ request }) => request.destination === 'document',
      },
    ],
  },
});

// Let the page trigger activation after the user accepts the update prompt.
self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});

serwist.addEventListeners();

Serwist's own examples set skipWaiting: true, which activates every new worker immediately. For an App Router app that lazy-loads route chunks, that invites the chunk-mismatch problem described on Updating service workers. Unless your deployment keeps old /_next/static files available for a while, prefer the prompt pattern.

app/layout.tsx
import type { Metadata, Viewport } from 'next';
import type { ReactNode } from 'react';
import { SerwistProvider } from '@serwist/turbopack/react';

export const metadata: Metadata = {
  applicationName: 'Acme Tasks',
  title: { default: 'Acme Tasks', template: '%s · Acme Tasks' },
  appleWebApp: { capable: true, statusBarStyle: 'default', title: 'Tasks' },
};

export const viewport: Viewport = { themeColor: '#1e3a8a' };

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <SerwistProvider swUrl="/serwist/sw.js" disable={process.env.NODE_ENV === 'development'}>
          {children}
        </SerwistProvider>
      </body>
    </html>
  );
}

SerwistProvider props and defaults, from @serwist/turbopack 9.5.12:

Prop Default Behavior
swUrl required Script URL passed to @serwist/window's Serwist
disable false Skip creating the Serwist instance
register true Call register() unless the current page is outside the scope
options.scope "/" Registration scope (needs Service-Worker-Allowed, which the route sends)
options.type "module" The esbuild output is an ES module, so this must stay "module"
cacheOnNavigation true Patches history.pushState and replaceState to post a CACHE_URLS message with each new URL, so client-side navigations are cached like full loads
reloadOnOnline true Reloads the page when the online event fires

Two consequences of these defaults. First, a module service worker needs module-worker support: Chrome 91, Safari 15 and Firefox 147 according to MDN's compatibility data. In Firefox ESR releases before 147 registration fails, and the site simply works without a worker. Second, reloadOnOnline reloads the whole page when connectivity returns, discarding unsaved form state. Set it to false in apps where users type offline.

@serwist/next on webpack

If you keep webpack, @serwist/next compiles swSrc in a child compilation and writes swDest:

next.config.mjs
import { spawnSync } from 'node:child_process';
import withSerwistInit from '@serwist/next';

const revision =
  spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf-8' }).stdout?.trim() || crypto.randomUUID();

const withSerwist = withSerwistInit({
  swSrc: 'app/sw.ts',
  swDest: 'public/sw.js',
  additionalPrecacheEntries: [{ url: '/~offline', revision }],
  cacheOnNavigation: true,                        // default false in this plugin
  reloadOnOnline: false,                          // default true
  disable: process.env.NODE_ENV === 'development',
});

export default withSerwist({ reactStrictMode: true });
package.json (scripts)
{
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start"
  }
}

The plugin options add cacheOnNavigation (default false), disable (false), register (true), reloadOnOnline (true), scope, swUrl ("/sw.js") and globPublicPatterns (["**/*"]) to the standard injectManifest options. Add public/sw* and public/swe-worker* to .gitignore, add "types": ["@serwist/next/typings"] and "lib": ["webworker"] to tsconfig.json, and exclude public/sw.js from type checking.

What defaultCache does

defaultCache is an ordered list of runtime routes. In development (NODE_ENV !== "production"), it's a single NetworkOnly route, so development never serves stale code. In production, the first matching rule wins:

Matcher Strategy Cache name Limits
fonts.gstatic.com CacheFirst google-fonts-webfonts 4 entries, 365 days
fonts.googleapis.com StaleWhileRevalidate google-fonts-stylesheets 4 entries, 7 days
Font file extensions StaleWhileRevalidate static-font-assets 4 entries, 7 days
Image extensions StaleWhileRevalidate static-image-assets 64 entries, 30 days
/_next/static/…js CacheFirst next-static-js-assets 64 entries, 1 day
/_next/image?url= StaleWhileRevalidate next-image 64 entries, 1 day
.mp3, .wav, .ogg / .mp4, .webm CacheFirst + RangeRequestsPlugin static-audio-assets / static-video-assets 32 entries, 1 day
Other .js / .css, .less StaleWhileRevalidate static-js-assets / static-style-assets 48 / 32 entries, 1 day
/_next/data/…json (Pages Router) NetworkFirst next-data 32 entries, 1 day
.json, .xml, .csv NetworkFirst static-data-assets 32 entries, 1 day
/api/auth/* NetworkOnly (10 s timeout) none none
Same-origin GET /api/* NetworkFirst (10 s timeout) apis 16 entries, 1 day
RSC: 1 and Next-Router-Prefetch: 1 NetworkFirst pages-rsc-prefetch 32 entries, 1 day
RSC: 1 NetworkFirst pages-rsc 32 entries, 1 day
Same-origin HTML NetworkFirst pages 32 entries, 1 day
Other same-origin NetworkFirst others 32 entries, 1 day
Cross-origin NetworkFirst (10 s timeout) cross-origin 32 entries, 1 hour

Review this table against your privacy requirements before you ship it unchanged. Every same-origin GET /api/* response is cached for up to a day, and so are App Router RSC payloads, which are per-user if the page reads cookies. On a shared device, the next user can see the previous user's data while offline. The Cache Storage API has no notion of the signed-in user, so either exclude authenticated routes (put a NetworkOnly rule for them before ...defaultCache), or clear the apis, pages-rsc and pages caches on sign-out. See Service worker security.

RSC requests, prefetching and the offline fallback

App Router client navigations don't fetch HTML. They fetch React Server Component payloads from the same URL with an RSC: 1 request header, and prefetches add Next-Router-Prefetch: 1. Since the two responses differ but share a URL, they must be cached separately, which is what the two RSC rules above do. They rely on the header, so they break if a proxy or CDN strips it or ignores Vary. Offline, a client-side navigation to an uncached route receives no RSC payload. The document fallback doesn't apply, because the request isn't a document request. In current versions the router responds to a failed RSC fetch by attempting a full-page navigation, and the worker answers that document request with /~offline. Test this path in DevTools with the network set to offline, because it's the one users hit most.

The experimental useOffline hook

Next.js 16.x added an experimental, production-discouraged experimental.useOffline flag that works without a service worker. It listens to offline and online events, treats a failed framework fetch() (other than an abort or timeout) as proof of being offline, and polls with a HEAD request that carries the RSC header. The poll is aborted after 200 ms, and a still-pending request counts as "online", because a truly offline request fails immediately. Polling delays step through 500 ms, 1 s and 2 s, then stay at 3 s. Blocked navigations, prefetches and Server Actions are retried once connectivity returns. useOffline() from next/offline returns the state for UI. It complements a worker, because it retries requests but doesn't cache anything. Don't confuse it with offline support.

Legacy Next.js PWA packages

next-pwa (last published in 2022) and @ducanh2912/next-pwa (last published in 2024, and succeeded by Serwist) wrap Workbox's webpack plugin and don't work with Turbopack builds. If you inherit one, migrate to Serwist. Keep the worker URL (/sw.js) the same so existing registrations update in place rather than leaving an orphaned worker behind.

Nuxt with @vite-pwa/nuxt

Nuxt 3 and 4 use @vite-pwa/nuxt (1.1.1, declaring Nuxt compatibility >=3.6.5), a Nuxt module that runs vite-plugin-pwa inside Nuxt's Vite build. It replaced @nuxtjs/pwa, which targeted Nuxt 2. The option surface is vite-plugin-pwa's own, so the Vite PWA plugin page covers registerType, workbox and injectManifest in detail. What the module adds or changes:

Behavior Detail from the module source
Output directory Nitro's output.publicDir, falling back to .output/public
dontCacheBustURLsMatching Derived from app.buildAssetsDir (_nuxt/ by default)
navigateFallback Defaults to app.baseURL (/) unless you set the key, even to null
autoUpdate Forces skipWaiting and clientsClaim when client.registerPlugin is on, or injectRegister is "script" or "inline"
Payloads With payload extraction on and prerendered routes present, adds **/_payload.json to globPatterns. experimental.enableWorkboxPayloadQueryParams adds a runtime rule that retries _payload.json?… requests without the query string when offline.
App manifest With Nuxt's experimental.appManifest (Nuxt 3.8+), precaches _nuxt/builds/**/*.json and transforms the latest.json entry
Client options client.installPrompt (true or a localStorage key; default key vite-pwa:hide-install), client.periodicSyncForUpdates (seconds), client.registerPlugin (default true)
Other options registerWebManifestInRouteRules, writePlugin (debug)
Runtime hooks service-worker:registered, service-worker:registration-failed, service-worker:activated; build hook pwa:beforeBuildServiceWorker

The navigateFallback default is the source of Nuxt's most common PWA bug. In a server-rendered Nuxt app (nuxt build, not nuxt generate), / isn't a static file in .output/public unless you prerender it. Workbox's NavigationRoute then looks up a URL that was never precached and fails every offline navigation with a non-precached-url error. Either prerender the fallback route, or turn the SPA fallback off and serve an offline page:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@vite-pwa/nuxt'],
  routeRules: {
    '/offline': { prerender: true },       // emits .output/public/offline/index.html
  },
  pwa: {
    registerType: 'prompt',
    manifest: {
      id: '/',
      name: 'Acme Tasks',
      short_name: 'Tasks',
      theme_color: '#1e3a8a',
      background_color: '#ffffff',
      icons: [
        { src: '/pwa-192x192.png', sizes: '192x192', type: 'image/png' },
        { src: '/pwa-512x512.png', sizes: '512x512', type: 'image/png' },
        { src: '/maskable-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
      ],
    },
    workbox: {
      navigateFallback: null,              // SSR: never answer navigations with a shell
      globPatterns: ['**/*.{js,css,html,png,svg,ico,woff2}'],
      runtimeCaching: [
        {
          urlPattern: ({ request, sameOrigin }) => sameOrigin && request.mode === 'navigate',
          handler: 'NetworkFirst',
          options: {
            cacheName: 'pages',
            networkTimeoutSeconds: 4,
            precacheFallback: { fallbackURL: '/offline/index.html' },
          },
        },
      ],
    },
    client: { installPrompt: true, periodicSyncForUpdates: 3600 },
    devOptions: { enabled: false },
  },
});

precacheFallback is a generateSW runtime-caching option that serves a precached URL when the handler fails. It requires the fallback file to match globPatterns, which offline/index.html does. Render <NuxtPwaManifest /> (or <VitePwaManifest />) once in app.vue so the manifest link appears on every route, and drive the update prompt from useNuxtApp().$pwa, whose needRefresh, offlineReady, updateServiceWorker(), showInstallPrompt, install() and cancelInstall() members mirror vite-plugin-pwa's registration API.

SvelteKit: the $service-worker module

SvelteKit has first-class service worker support without any plugin. If src/service-worker.js, src/service-worker.ts or src/service-worker/index.{js,ts} exists, SvelteKit bundles it in a separate Vite build and emits it as service-worker.js at the root of the client output. It also injects a registration script into every server-rendered page. The worker can import only $service-worker, $env/static/public and, in current 2.x releases, $app/env/public. Importing anything else from $app or $env fails the build with an explicit error.

What $service-worker exports

Export Type Contents
base string The deployment base path, computed at runtime from location.pathname of the worker script, so it's correct even when the app is served from a subdirectory. It's equivalent to config.kit.paths.base. paths.assets can't be used with a service worker.
build string[] Every file Vite generated for the client (JS, CSS and imported assets), each prefixed with base. Empty in development. When output.bundleStrategy is "inline", inlined files are removed from the list.
files string[] Files in static/ (or config.kit.files.assets), filtered by config.kit.serviceWorker.files, which by default excludes .DS_Store
prerendered string[] Pathnames of prerendered pages and endpoints. Empty in development.
version string config.kit.version.name, which defaults to the build timestamp. Set it to a commit SHA for deterministic builds.

build and files are the precache list. Because every build URL contains a content hash under _app/immutable/, a cache named after version gives you a correct revisioned precache: a new deployment gets a new cache name, installs everything into it, and deletes the old cache on activate.

A production SvelteKit service worker

The example in SvelteKit's documentation caches every successful GET response in the same cache as the precache. The version below separates the immutable precache from runtime caches, handles navigations network-first with navigation preload and a prerendered offline page, and waits for the user before activating.

src/service-worker.ts
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
/// <reference types="@sveltejs/kit" />
import { base, build, files, prerendered, version } from '$service-worker';

const sw = self as unknown as ServiceWorkerGlobalScope;

const PRECACHE = `precache-${version}`;
const PAGES = 'pages-v1';                 // navigations; survives deployments
const OFFLINE_URL = `${base}/offline`;    // src/routes/offline/+page.ts: export const prerender = true

// build = hashed JS/CSS, files = static/, prerendered includes /offline.
const PRECACHE_URLS = [...build, ...files, ...prerendered];
const PRECACHED = new Set(PRECACHE_URLS);

sw.addEventListener('install', (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(PRECACHE);
      // cache: 'reload' bypasses the HTTP cache so an old CDN copy isn't precached.
      await cache.addAll(PRECACHE_URLS.map((url) => new Request(url, { cache: 'reload' })));
    })(),
  );
  // No skipWaiting(): the page asks for it after the user accepts the update.
});

sw.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      for (const key of await caches.keys()) {
        if (key.startsWith('precache-') && key !== PRECACHE) await caches.delete(key);
      }
      if (sw.registration.navigationPreload) await sw.registration.navigationPreload.enable();
      await sw.clients.claim();
    })(),
  );
});

sw.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') sw.skipWaiting();
});

sw.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;
  const url = new URL(request.url);
  if (url.origin !== sw.location.origin) return;

  // 1. Immutable build output and static files: cache-first from the precache.
  if (PRECACHED.has(url.pathname) && request.mode !== 'navigate') {
    event.respondWith(
      caches.open(PRECACHE).then(async (cache) => (await cache.match(url.pathname)) ?? fetch(request)),
    );
    return;
  }

  // 2. Navigations: network-first (with navigation preload), then cache, then offline page.
  if (request.mode === 'navigate') {
    event.respondWith(
      (async () => {
        try {
          const response = (await event.preloadResponse) ?? (await fetch(request));
          if (response.ok && !response.headers.get('cache-control')?.includes('no-store')) {
            const copy = response.clone();
            event.waitUntil(caches.open(PAGES).then((c) => c.put(request, copy)));
          }
          return response;
        } catch {
          const cached = await caches.match(request, { cacheName: PAGES });
          return cached ?? (await caches.match(OFFLINE_URL, { cacheName: PRECACHE })) ?? Response.error();
        }
      })(),
    );
    return;
  }

  // 3. Everything else (including __data.json from client-side navigation):
  //    go to the network. Caching load() data here would serve stale,
  //    possibly per-user, data; do it deliberately with IndexedDB instead.
});

A few SvelteKit-specific details make this work. Client-side navigations to routes with server load functions fetch __data.json under the route path (for example /blog/__data.json), not the HTML. When that fetch fails offline, SvelteKit first runs updated.check() (which also fails offline) and then renders the nearest +error.svelte. Make that page offline-aware: check navigator.onLine and offer a button that calls location.reload(), because a full reload goes through the worker's navigation handler and gets the cached page or the offline page. The prerendered list includes /offline only if that route is prerendered. event.waitUntil() around the cache write keeps the worker alive until the copy is stored. Handling fetch events and Offline UX & fallbacks explain the general patterns.

Registration and update detection

SvelteKit's injected script calls navigator.serviceWorker.register('<base>/service-worker.js') inside a load listener, wrapping the URL in a Trusted Types policy named sveltekit-trusted-url when Trusted Types are available. In development, it registers with { type: 'module' }, because the worker isn't bundled in dev. That's why the SvelteKit docs note that development mode works only in browsers that support module service workers (Chrome 91, Safari 15 and Firefox 147 in MDN's data). kit.serviceWorker accepts:

Option Default Notes
register true false disables the injected script, for example when you register through @vite-pwa/sveltekit's virtual module
options none A RegistrationOptions object passed to register() (only when register is true). type is forced to 'module' in development.
files (file) => !/\.DS_Store/.test(file) Filters $service-worker.files

SvelteKit calls registration.update() itself only in one situation: when a navigation fails and updated.check() detects a new deployment, it tries to update the worker before forcing a full reload. For everything else, use version.pollInterval (milliseconds; default 0, meaning no polling) and the reactive updated object from $app/state, whose current flips to true when a new version is deployed and whose check() polls immediately:

src/routes/+layout.svelte
<script lang="ts">
  import { onMount } from 'svelte';
  import { updated } from '$app/state';
  import { afterNavigate } from '$app/navigation';

  let { children } = $props();
  let waiting = $state<ServiceWorker | null>(null);
  let registration: ServiceWorkerRegistration | undefined;

  onMount(async () => {
    if (!('serviceWorker' in navigator)) return;
    registration = await navigator.serviceWorker.getRegistration();
    if (!registration) return;
    // A worker may already be waiting from a previous visit.
    if (registration.waiting && navigator.serviceWorker.controller) waiting = registration.waiting;
    registration.addEventListener('updatefound', () => {
      const next = registration?.installing;
      next?.addEventListener('statechange', () => {
        // "installed" with an existing controller means an update, not a first install.
        if (next.state === 'installed' && navigator.serviceWorker.controller) waiting = next;
      });
    });
  });

  // Check for a new worker on each client-side navigation (offline: ignore the error).
  afterNavigate(() => {
    registration?.update().catch(() => {});
  });

  function applyUpdate() {
    if (!waiting) return location.reload(); // new deployment detected by `updated` only
    navigator.serviceWorker.addEventListener('controllerchange', () => location.reload(), { once: true });
    waiting.postMessage({ type: 'SKIP_WAITING' });
  }
</script>

{#if waiting || updated.current}
  <div role="status" class="update-banner">
    A new version is available.
    <button type="button" onclick={applyUpdate}>Reload</button>
  </div>
{/if}

{@render children()}
svelte.config.js
import adapter from '@sveltejs/adapter-node';
import { execSync } from 'node:child_process';

export default {
  kit: {
    adapter: adapter(),
    version: {
      name: execSync('git rev-parse HEAD').toString().trim(), // deterministic cache names
      pollInterval: 5 * 60 * 1000,                            // drives `updated.current`
    },
    serviceWorker: { register: true, options: { updateViaCache: 'none' } },
  },
};

Choose @vite-pwa/sveltekit instead of the built-in worker when you want Workbox's strategies, generateSW or the assets generator. Its details, including the rule that extra globPatterns need the client/ prefix, are on Vite PWA plugin.

React Router 8 (framework mode) and Remix

React Router 8.0.0 was released on 17 June 2026. It requires Node.js 22.22+, React 19.2.7+ and Vite 7 or later (@react-router/dev 8.4.0 declares vite ^7.0.0 || ^8.0.0). It's ESM-only and has dropped the react-router-dom package. Framework mode (the successor to Remix 2) has no PWA integration, and @vite-pwa/remix 0.2.0 supports only Remix 2 (@remix-run/dev >= 2.8.0). In practice, you write the worker yourself or post-process the client build with Workbox.

URLs your worker has to understand

Request Example What it is Worker policy
Document GET /projects/42 Server-rendered HTML Network-first, offline fallback
Single-fetch data GET /projects/42.data Loader data for client navigations (Turbo Stream encoding) Network-only, or network-first with care. It's per-user if loaders read cookies.
Root data (v8) GET /_.data Data for /. It was /_root.data before v8's trailing-slash-aware format; a trailing-slash URL /a/b/ uses /a/b/_.data. Same as above
Route discovery GET /__manifest?p=… Lazy route discovery (routeDiscovery.mode: "lazy", the default) Network-only. Offline, set routeDiscovery: { mode: "initial" } so the full route manifest ships in the first HTML.
Assets GET /assets/route-abc123.js Hashed client build output in build/client/assets/ Precache, cache-first

Lazy route discovery is the offline trap. With the default "lazy" mode, a client navigation to a route the browser hasn't seen yet first fetches /__manifest. Offline, that fails, even if every chunk is precached. For an offline-capable app, set routeDiscovery: { mode: "initial" } in react-router.config.ts so the route manifest is inlined into the document.

A Workbox injectManifest step for build/client

react-router.config.ts
import type { Config } from '@react-router/dev/config';

export default {
  ssr: true,
  routeDiscovery: { mode: 'initial' },  // no /__manifest fetches while offline
  prerender: ['/offline'],              // static offline page in build/client
} satisfies Config;
app/sw.ts
/// <reference lib="webworker" />
import { cleanupOutdatedCaches, matchPrecache, precacheAndRoute } from 'workbox-precaching';
import { registerRoute, setCatchHandler } from 'workbox-routing';
import { NetworkFirst, NetworkOnly } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

declare const self: ServiceWorkerGlobalScope & {
  __WB_MANIFEST: Array<{ url: string; revision: string | null } | string>;
};

precacheAndRoute(self.__WB_MANIFEST); // replaced with the list of build/client files
cleanupOutdatedCaches();

// Loader data and route discovery: always fresh; never cached across users.
registerRoute(
  ({ url, sameOrigin }) => sameOrigin && (url.pathname.endsWith('.data') || url.pathname === '/__manifest'),
  new NetworkOnly(),
);

// Documents: network-first so SSR output stays current; cache as a fallback.
registerRoute(
  ({ request, sameOrigin }) => sameOrigin && request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages',
    networkTimeoutSeconds: 4,
    plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 })],
  }),
);

// Anything that failed: navigations get the prerendered offline page.
setCatchHandler(async ({ request }) => {
  if (request.mode === 'navigate') {
    return (await matchPrecache('/offline/index.html')) ?? Response.error();
  }
  return Response.error();
});

self.addEventListener('message', (event) => {
  if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
});
scripts/build-sw.mjs
// Run after `react-router build`: node scripts/build-sw.mjs
import { build } from 'esbuild';
import { injectManifest } from 'workbox-build';

// 1. Bundle the worker outside build/client so it isn't globbed into its own precache.
await build({
  entryPoints: ['app/sw.ts'],
  outfile: 'build/.sw-bundle.js',
  bundle: true,
  format: 'iife',
  minify: true,
  target: 'es2022',
  define: { 'process.env.NODE_ENV': '"production"' }, // Workbox strips its dev logging
});

// 2. Inject the precache manifest and write the final worker to the client root.
const { count, size, warnings } = await injectManifest({
  swSrc: 'build/.sw-bundle.js',
  swDest: 'build/client/sw.js',
  globDirectory: 'build/client',
  globPatterns: ['assets/**/*.{js,css,woff2}', 'offline/index.html', '*.{ico,png,svg,webmanifest}'],
  dontCacheBustURLsMatching: /^assets\//,          // Vite-hashed: no revision needed
  maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
});
warnings.forEach((w) => console.warn(w));
console.log(`sw.js: precached ${count} files, ${(size / 1024).toFixed(1)} KiB`);
package.json (scripts)
{
  "scripts": {
    "build": "react-router build && node scripts/build-sw.mjs"
  }
}

Register the worker from app/root.tsx in a useEffect (it must not run during SSR), and serve /sw.js with Cache-Control: no-cache. With ssr: false (SPA mode), React Router writes build/client/index.html. If you also prerender /, it writes a separate __spa-fallback.html for non-prerendered paths, and that file, not index.html, is the right navigateFallback. workbox-build 7.4 requires Node.js 20 or later, which React Router 8's Node.js 22.22 floor already satisfies.

Remix 2 (@remix-run/dev 2.17.5) is in maintenance. If you still run it, @vite-pwa/remix provides a Remix preset plus plugin. The remix package's next tag points to 3.0.0 release candidates, a different architecture that this page doesn't cover.

Astro

Astro builds multi-page sites, so its PWA story differs from SPA frameworks: every page is real HTML, and a service worker should cache pages, not substitute a shell. @vite-pwa/astro 1.2.0 wraps vite-plugin-pwa, and you add the manifest link from virtual:pwa-info and register from a <script> in your layout, as shown on Vite PWA plugin.

The compatibility problem in 2026: Astro 6.0.0 was released on 10 March 2026 and Astro 7.0.0 on 22 June 2026 (7.3.5 is current, on Vite 8), but @vite-pwa/astro 1.2.0 declares astro peers ^1.6.0 || … || ^5.0.0. A pull request adding Astro 6 support (vite-pwa/astro#73), opened in March 2026, was closed without merging in August 2026, and the issues requesting Astro 6 (#72) and Astro 7 (#74) support were still open in September 2026. Installing on Astro 6 or 7 therefore fails with a peer-dependency error unless you override it:

package.json
{
  "overrides": {
    "@vite-pwa/astro": {
      "astro": "$astro"
    }
  }
}
pnpm-workspace.yaml
peerDependencyRules:
  allowedVersions:
    "@vite-pwa/astro>astro": "7"

An override only silences the resolver. It doesn't make the integration tested against Astro 7's build pipeline, so verify the output (the worker in dist/, the precache list, and the manifest link on every page) before you rely on it. The integration-free alternative is to run Workbox after astro build, which works with any Astro version for static output:

workbox-config.cjs
module.exports = {
  globDirectory: 'dist/',
  globPatterns: ['**/*.{html,css,js,svg,png,webp,avif,woff2,webmanifest}'],
  globIgnores: ['**/node_modules/**', 'sw.js', 'workbox-*.js'],
  swDest: 'dist/sw.js',
  dontCacheBustURLsMatching: /^_astro\//,  // Astro's hashed asset directory
  ignoreURLParametersMatching: [/^utm_/, /^fbclid$/],
  // No navigateFallback: an MPA serves each page's own precached HTML.
  cleanupOutdatedCaches: true,
  clientsClaim: true,
  skipWaiting: false,
  maximumFileSizeToCacheInBytes: 2 * 1024 * 1024,
  runtimeCaching: [
    {
      urlPattern: ({ request, sameOrigin }) => sameOrigin && request.destination === 'image',
      handler: 'StaleWhileRevalidate',
      options: { cacheName: 'images', expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 } },
    },
  ],
};
package.json (scripts)
{
  "scripts": {
    "build": "astro build && workbox generateSW workbox-config.cjs"
  }
}

Precaching every HTML page suits documentation-sized sites. For thousands of pages, precache only the key pages and let a NetworkFirst navigation route cache the rest as they're visited. Watch trailing slashes: Workbox's precache route maps /guide/ to guide/index.html (directoryIndex) and /guide to guide.html (cleanURLs), but not /guide to guide/index.html. With Astro's default build.format: "directory", set trailingSlash: "always" so internal links match the precached URLs. For output: "server", the client assets live in dist/client/, so point globDirectory there and precache only assets, not HTML.

Vue

A Vue 3 single-page app is built with Vite (scaffolded by create-vue), so the PWA layer is vite-plugin-pwa itself. virtual:pwa-register/vue exports useRegisterSW(), which returns needRefresh and offlineReady refs and an updateServiceWorker() function to wire into a reload prompt component. It's documented with full code on Vite PWA plugin. Two Vue-specific notes:

  • Vue Router history mode needs navigateFallback: 'index.html' (vite-plugin-pwa's default) plus a navigateFallbackDenylist for server routes such as /api/. Hash mode needs no fallback, because all navigations are to /.
  • Vue CLI projects with @vue/cli-plugin-pwa (5.0.9) are on webpack 5 and Workbox's webpack plugin. Vue CLI is in maintenance mode, and its documentation points new projects to create-vue, which uses Vite. When migrating, keep the worker file name the plugin used (service-worker.js by default), or deploy a kill switch at that URL, so returning visitors don't keep an orphaned worker.

For Nuxt, use @vite-pwa/nuxt as described above.

SolidStart 2

SolidStart 2.0.0 (4 August 2026) replaced Vinxi with Vite's Environment API directly. It requires Node.js 24 or later and Vite 8 or later (2.0.5 declares vite ^8 || ^9), moves framework configuration from app.config.ts into vite.config.ts as the solidStart() plugin, and deploys through Nitro 3's Vite plugin (nitro() from nitro/vite). There's no SolidStart-specific PWA package, and vite-plugin-pwa has no official SolidStart integration. If you try vite-plugin-pwa, confirm that sw.js and its precache list end up in the directory your Nitro preset deploys, because the build involves several Vite environments.

The approach that doesn't depend on plugin internals is the same post-build injectManifest step used for React Router above, pointed at the static directory of your Nitro output. For Nitro's Node.js server preset, that's .output/public. Check your preset's documentation for the equivalent directory on other hosts.

src/entry-client.tsx (addition)
// Add after the existing mount(...) call.
if ('serviceWorker' in navigator && import.meta.env.PROD) {
  window.addEventListener('load', () => {
    navigator.serviceWorker
      .register('/sw.js', { updateViaCache: 'none' })
      .catch((err) => console.error('Service worker registration failed', err));
  });
}

@solidjs/start supports ssr: false for client-only rendering. In that mode, a shell-based navigateFallback is appropriate. With SSR on, use network-first navigations and an offline page, exactly as for React Router. Solid's server functions are POST requests by default, and service workers never cache POST responses, so they need no special worker rule.

Qwik

Qwik City 1.x (@builder.io/qwik-city 1.20.1) historically shipped a service worker whose job was prefetching Qwik's fine-grained code segments, not offline support. You created it as src/routes/service-worker.ts calling setupServiceWorker(), and registered it with <ServiceWorkerRegister /> in root.tsx. That worker is now deprecated. In 1.20.1, setupServiceWorker() is an empty function, and its documentation comment explains that "Qwik now automatically embeds preloading logic into the application", using modulepreload and a bundle graph.

The build still treats src/routes/service-worker.ts as a service worker entry and serves it at /service-worker.js. That gives you a clean place for real PWA logic:

  • If your service-worker.ts contains only setupServiceWorker(), remove it once users have picked up the new version, together with <ServiceWorkerRegister />. When no worker entry exists (and always under the dev server), <ServiceWorkerRegister /> emits a script that unregisters any worker whose script URL ends in service-worker.js and deletes the old QwikBuild… cache. That's a built-in kill switch.
  • If you want offline support, keep the file, remove the setupServiceWorker() call, and write your own install, activate and fetch handlers. Qwik's hashed bundles live under /build/ and can be cached cache-first. Navigations should be network-first with an offline page, because Qwik pages are server-rendered with resumable state embedded in the HTML. A cached page resumes correctly only if the bundles it references are still cached.

Qwik 2 is published under @qwik.dev/core and @qwik.dev/router, but in September 2026 its latest tag was still a beta (2.0.0-beta.45). The community @qwikdev/pwa package was last published in February 2024 (0.0.4), so evaluate it carefully before adopting it.

Legacy stacks: Create React App and Gatsby

Create React App

The React team deprecated Create React App on 14 February 2025. New installs print a deprecation warning, the project continues "in maintenance mode", and the React docs recommend a framework (Next.js, React Router, Expo) or a build tool (Vite, Parcel, Rsbuild) instead. react-scripts 5.0.1 (April 2022) is still the latest version. Its PWA support works like this:

  • npx create-react-app my-app --template cra-template-pwa adds src/service-worker.js (Workbox 6 modules: precacheAndRoute(self.__WB_MANIFEST), an app-shell NavigationRoute bound to index.html that skips URLs with file extensions and /_ paths, and a SKIP_WAITING message listener) plus src/serviceWorkerRegistration.js.
  • react-scripts build runs Workbox's InjectManifest webpack plugin (workbox-webpack-plugin ^6.4.1) only if src/service-worker.js exists.
  • src/index.js calls serviceWorkerRegistration.unregister() by default. You opt in by changing it to register({ onUpdate, onSuccess }). onUpdate fires when a new worker is installed and waiting.

Migrating to Vite plus vite-plugin-pwa is mostly mechanical: injectManifest with the same self.__WB_MANIFEST injection point accepts the CRA worker nearly unchanged. Keep the output file name service-worker.js (or set vite-plugin-pwa's filename), so existing registrations update in place. See Migrating an existing site.

Gatsby

gatsby-plugin-manifest generates the manifest and icons, and gatsby-plugin-offline (6.16.0, January 2026) generates a worker with workbox-build ^4.3.1, a Workbox release from May 2019. It has an app-shell design that predates most of Workbox's current behavior and all of its bug fixes since then. For a Gatsby site you keep, consider dropping gatsby-plugin-offline and generating a worker with current Workbox after gatsby build (the public/ directory is the output, and the same workbox-config.cjs approach shown for Astro applies). If you remove offline support entirely, deploy a self-unregistering worker at /sw.js. gatsby-plugin-remove-serviceworker (1.0.0, published in 2017 and never updated) exists for exactly this purpose, although a hand-written kill switch is easier to audit. The general kill-switch pattern is on Updating service workers.

Side-by-side comparison

Worker source Precache manifest Navigation default Update UX Push
Angular Prebuilt ngsw-worker.js ngsw.json (SHA-1 per file) Cached index.html (performance) SwUpdate.versionUpdates, activateUpdate() SwPush built in
Next.js + Serwist Your app/sw.ts self.__SW_MANIFEST Serwist fallbacks (for example /~offline) @serwist/window events; skipWaiting in examples Hand-written handlers
Nuxt vite-plugin-pwa (generateSW default) self.__WB_MANIFEST navigateFallback: '/' (breaks SSR unless changed) $pwa.needRefresh Custom injectManifest worker
SvelteKit Your src/service-worker.ts $service-worker build, files, prerendered Whatever you write updated from $app/state + your prompt Hand-written handlers
React Router 8 Your worker + workbox-build self.__WB_MANIFEST Your NavigationRoute Your prompt Hand-written handlers
Astro @vite-pwa/astro or Workbox CLI self.__WB_MANIFEST None (MPA) virtual:pwa-register Custom worker
Qwik City 1.x Your src/routes/service-worker.ts None built in Whatever you write Your prompt Hand-written handlers

Common pitfalls

  • Precaching the server bundle. Globs such as **/*.js run from the project root or .svelte-kit/output pick up server code. Always glob the client output (build/client, .output/public, dist/client, the client/ prefix for SvelteKit).
  • An SPA fallback in an SSR app. navigateFallback: '/' or index.html serves the wrong page for every route and bypasses SSR, and fails outright when / isn't prerendered (Nuxt). Use network-first navigations with an offline page instead. See SPA vs MPA PWAs.
  • Caching per-user data. Serwist's defaultCache caches /api/* and RSC payloads, Workbox examples cache .data or __data.json, and Angular dataGroups can match authenticated APIs. Anything cached is visible to the next person on the device. Exclude authenticated routes or clear caches on sign-out.
  • skipWaiting with lazy route chunks. Angular's activateUpdate() without a reload, Serwist examples with skipWaiting: true, and autoUpdate modes can all load a new shell against old chunks, or the reverse. Prompt and reload, and keep old hashed assets deployed for a few days.
  • Framework-owned routes breaking offline navigation. React Router's /__manifest lazy discovery, Next.js RSC requests and Nuxt _payload.json?… query strings each need an explicit decision in the worker.
  • Worker in development. A worker registered during next dev, vite dev or ng serve keeps serving old code after you stop the dev server. Every integration disables registration in dev by default. Keep it that way, and use DevTools' Update on reload when you do test a worker locally. See Browser DevTools.
  • Changing the worker URL. Moving from service-worker.js (CRA, Vue CLI, Qwik) to sw.js, or from /sw.js to /serwist/sw.js, leaves the old registration in place, because nothing ever tells it to go away. Serve a kill-switch worker at the old URL or keep the old URL.
  • CDN caching of the worker or ngsw.json. Serve worker scripts, ngsw.json and the manifest with Cache-Control: no-cache. A CDN caching ngsw.json for a day delays every Angular update by a day, and a CDN caching hashed files under the wrong name causes Angular's hash check to fail and the version to be rejected.

Debugging framework workers

  1. Confirm which worker controls the page. Check DevTools > Application > Service workers for the script URL, status and scope. Verify that the URL is the one your integration generates (ngsw-worker.js, /serwist/sw.js, /service-worker.js, /sw.js).
  2. Inspect the precache list. Open the worker source and search for the injected manifest (__WB_MANIFEST or __SW_MANIFEST is replaced by an array), or fetch /ngsw.json. Server files, source maps or HTML that shouldn't be there show up immediately.
  3. Angular: read /ngsw/state. The driver state and the last update check explain almost every "stuck on an old version" report.
  4. Test offline navigation to an unvisited route. Offline mode in DevTools, then a client-side link click, then a hard reload. The three paths exercise different handlers (RSC or .data fetches, document fallbacks, and the precache).
  5. Automate it. Playwright can block service workers per context (serviceWorkers: "block") and wait for the serviceworker event, so you can test with and without the worker in CI. See Automated testing.

Further reading

On this site

External references