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/pwawires upngsw-worker.js, which you drive withngsw-config.json, and theSwUpdateandSwPushservices. It uses its own hash-table versioning, not Workbox. - Next.js 16 builds with Turbopack by default.
@serwist/nexthooks into webpack, so it needsnext 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 exposesbuild,files,prerendered,versionandbasethrough$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/astro1.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-buildinjectManifeststep 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-offlinestill 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).
- 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 staticindex.html. See the manifest members reference. - 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. - 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. - 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.jsonand/__manifest. A worker that doesn't know about them either caches personalized data or breaks offline navigation. - 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¶
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:
{
"$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.
{
"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 andclients.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 callactivateUpdate(). - Update checks. The first navigation request after the worker starts schedules a
check-updates-on-navigationtask. The worker fetchesngsw.json?ngsw-cache-bust=<random>, and if the hash differs, it downloads the new version'sprefetchgroups in the background and emitsVERSION_DETECTED, thenVERSION_READYorVERSION_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_READYoften 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.
NORMALis the healthy state.EXISTING_CLIENTS_ONLYmeans the worker has no clean copy of the latest version: existing tabs keep running from cache, and new loads go to the network.SAFE_MODEmeans the worker can't trust its caches and serves everything from the network. - Unhashed content. Resources matched by
urlspatterns 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:
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.
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';
}
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:
// 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_ONLYorSAFE_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-bypassheader 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.jsonreturns 404, the worker deletes all its caches and unregisters itself. Deletingngsw.jsonfrom 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().
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:
- 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.
- 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. - Don't rely on
beforeinstallpromptdetection. The guide'sInstallPromptshows iOS instructions by sniffing the user agent and checksdisplay-mode: standalone. That's fine as a hint, but see Install prompts & custom UI for a cross-browser approach.
'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¶
import { withSerwist } from '@serwist/turbopack';
// withSerwist only adds esbuild and esbuild-wasm to serverExternalPackages.
export default withSerwist({
reactStrictMode: true,
});
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.
/// <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.
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:
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 });
{
"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:
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.
/// <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:
<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()}
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¶
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;
/// <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();
});
// 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`);
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:
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:
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 } },
},
],
};
{
"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 anavigateFallbackDenylistfor 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 tocreate-vue, which uses Vite. When migrating, keep the worker file name the plugin used (service-worker.jsby 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.
// 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.tscontains onlysetupServiceWorker(), 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 inservice-worker.jsand deletes the oldQwikBuild…cache. That's a built-in kill switch. - If you want offline support, keep the file, remove the
setupServiceWorker()call, and write your owninstall,activateandfetchhandlers. 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-pwaaddssrc/service-worker.js(Workbox 6 modules:precacheAndRoute(self.__WB_MANIFEST), an app-shellNavigationRoutebound toindex.htmlthat skips URLs with file extensions and/_paths, and aSKIP_WAITINGmessage listener) plussrc/serviceWorkerRegistration.js.react-scripts buildruns Workbox'sInjectManifestwebpack plugin (workbox-webpack-plugin^6.4.1) only ifsrc/service-worker.jsexists.src/index.jscallsserviceWorkerRegistration.unregister()by default. You opt in by changing it toregister({ onUpdate, onSuccess }).onUpdatefires 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
**/*.jsrun from the project root or.svelte-kit/outputpick up server code. Always glob the client output (build/client,.output/public,dist/client, theclient/prefix for SvelteKit). - An SPA fallback in an SSR app.
navigateFallback: '/'orindex.htmlserves 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
defaultCachecaches/api/*and RSC payloads, Workbox examples cache.dataor__data.json, and AngulardataGroupscan match authenticated APIs. Anything cached is visible to the next person on the device. Exclude authenticated routes or clear caches on sign-out. skipWaitingwith lazy route chunks. Angular'sactivateUpdate()without a reload, Serwist examples withskipWaiting: true, andautoUpdatemodes 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
/__manifestlazy 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 devorng servekeeps 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) tosw.js, or from/sw.jsto/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.jsonand the manifest withCache-Control: no-cache. A CDN cachingngsw.jsonfor 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¶
- 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). - Inspect the precache list. Open the worker source and search for the injected manifest (
__WB_MANIFESTor__SW_MANIFESTis replaced by an array), or fetch/ngsw.json. Server files, source maps or HTML that shouldn't be there show up immediately. - Angular: read
/ngsw/state. The driver state and the last update check explain almost every "stuck on an old version" report. - 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
.datafetches, document fallbacks, and the precache). - Automate it. Playwright can block service workers per context (
serviceWorkers: "block") and wait for theserviceworkerevent, so you can test with and without the worker in CI. See Automated testing.
Further reading¶
On this site
- Vite PWA plugin: the plugin underneath the Nuxt, Astro, SvelteKit and Vue integrations
- Workbox fundamentals and Advanced Workbox
- Updating service workers
- Precaching & runtime caching
- SPA vs MPA PWAs
- Push notifications
- Service worker security
- PWABuilder: packaging any of these apps for app stores
External references
- Angular service worker configuration and DevOps guide (angular.dev)
- Angular
SwPushAPI and push notifications guide - How to build a PWA with Next.js and
manifest.jsonfile convention (nextjs.org) - Next.js 16 upgrade guide: Turbopack by default
- Serwist for Next.js and Serwist with Turbopack
- SvelteKit service workers and
$service-workerreference - @vite-pwa/nuxt and @vite-pwa/astro
- React Router changelog
- Sunsetting Create React App (react.dev)
- MDN: Service Worker API