Build Progressive Web Apps (PWAs) with offline support, installability, and caching strategies — for a plain static site by hand-writing the manifest/service worker directly, or for a build-pipeline framework (Next.js, Nuxt, SvelteKit, Vite/CRA/Angular) via that framework's own PWA integration, including inside a monorepo where the frontend is a separate package from a backend API. Trigger whenever the user mentions PWA, service workers, web app manifests, Workbox, 'add to home screen', or wa...
Installs into .claude/skills of the current project.
Are you the author of Progressive Web App?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/ghosteken-progressive-web-app)
---
name: progressive-web-app
description: Build Progressive Web Apps (PWAs) with offline support, installability, and caching strategies — for a plain static site by hand-writing the manifest/service worker directly, or for a build-pipeline framework (Next.js, Nuxt, SvelteKit, Vite/CRA/Angular) via that framework's own PWA integration, including inside a monorepo where the frontend is a separate package from a backend API. Trigger whenever the user mentions PWA, service workers, web app manifests, Workbox, 'add to home screen', or wants their web app to work offline, feel native, or be installable.
---
# Progressive Web Apps (PWAs)
## Overview
A Progressive Web App is a web application that uses modern browser capabilities to deliver a fast, reliable, and installable experience — even on unreliable networks. The three required pillars are:
1. **HTTPS** — Required in production for service workers to register (localhost is exempt for development).
2. **Web App Manifest** (`manifest.json`) — Makes the app installable and defines its appearance on device home screens.
3. **Service Worker** (`sw.js`) — A background script that intercepts network requests, manages caches, and enables offline functionality.
## When to Use This Skill
- Use when the user wants their web app to work offline or on unreliable networks.
- Use when building a mobile-first web project where users should be able to install the app to their home screen.
- Use when the user asks about caching strategies, service workers, or improving web app performance and resilience.
- Use when the user mentions Workbox, web app manifests, background sync, or push notifications for the web.
- Use when the user asks "can my website be installed like an app?" or "how do I make my site work offline?" — even if they don't use the word PWA.
## Determine the Repo's Nature First
Before anything else, use the `AskUserQuestion` tool to ask which of these the repo actually is — don't infer it silently, since it changes everything that follows:
- **A plain repo** — a static site or simple SPA with a hand-editable `index.html`, no framework build pipeline.
- **An existing PWA** — some PWA setup is already there (a manifest, a service worker, or both) and this is a refinement, not a from-scratch build.
- **A Next.js project** (or another build-pipeline framework — Nuxt, SvelteKit, Vite, CRA, Angular).
- **A web + API monorepo** — a frontend package alongside a separate backend/API package.
Skip asking only when the answer is already unambiguous from what's been stated or from a quick look at the repo root (e.g. a `next.config` file sitting right there makes "Next.js project" obvious) — but default to asking rather than assuming, since this is a case where the wrong guess derails everything that follows it: hand-rolling a static `sw.js` on a Next.js app breaks on the very first deploy, and generating a fresh manifest over an existing PWA setup overwrites real configuration. The answer determines which of the branches below applies, and whether Monorepo Placement and Check What Already Exists First need to be walked through as well (an existing PWA or a monorepo answer means both do; a plain repo skips straight to Step 1).
## Before You Start: Static Site vs. Build-Pipeline Framework
Steps 1-4 below are written for a plain site with a real `index.html` you control directly. Check which case actually applies before following them literally:
- **Plain static site / simple SPA with a hand-editable `index.html`** — Steps 1-4 apply as written: hand-write `manifest.json`, `app.js`, and `sw.js` directly.
- **A framework with its own build pipeline that fingerprints/hashes output files** (Next.js, Nuxt, SvelteKit, Vite, Create React App, Angular) — **do not hand-write a static `sw.js` that lists filenames.** The build hashes JS/CSS filenames on every deploy, so a manually-listed precache list goes stale immediately. Use that framework's build-integrated PWA plugin instead, which generates the service worker's precache manifest from the actual build output. For **Next.js** specifically:
- Use [`@serwist/next`](https://serwist.pages.dev/) (the actively maintained successor to `next-pwa`, which is unmaintained) — it wraps `next.config`, generates the service worker from the real build manifest on every build, and keeps precaching correct automatically.
- **App Router (Next.js 13+)** has a native manifest file convention — `app/manifest.ts` — instead of a hand-written `public/manifest.json`; Next.js serves it automatically. The manifest *fields* (`name`, `icons`, `display`, etc.) from Step 1 below still apply, just returned from that file instead of written as static JSON.
- Register the service worker from a small Client Component (`'use client'`) in the root layout, not an inline `<script>` in a static HTML file — there is no `index.html` to edit.
- For lighter needs than full offline caching — just knowing the connection dropped and retrying a failed navigation or server call automatically — Next.js also has an experimental connectivity-aware hook that doesn't require a service worker at all. Reach for the full service-worker approach (via `@serwist/next`) only when actual offline asset caching is the goal, not just graceful degradation on a flaky connection.
- The three caching strategies (Step 4), the offline fallback page, the manifest's required fields, and the shipping checklist all still apply regardless of framework — only *how* the files get generated changes.
### Monorepo placement
In a monorepo with a separate frontend and backend package (e.g. a NestJS API alongside a Next.js frontend as sibling workspace packages), everything above belongs **inside the frontend package only** (`apps/web/`, or whatever it's actually named) — never at the monorepo root, and never inside the backend package. A backend API gets no manifest and no service worker of its own; it's simply a data source the frontend's service worker may cache responses from (Step 4's Strategy C, stale-while-revalidate, for `/api/*` requests). If the frontend package's path isn't obvious from the workspace layout, confirm it before generating anything rather than guessing.
## Check What Already Exists First
Before generating anything, this is very often an **existing repo**, not an empty one — check for each of these before assuming it needs to be created from scratch:
- **A manifest** — `public/manifest.json`, `app/manifest.ts`, or the equivalent for whatever framework was identified above.
- **A service worker** — a hand-written `sw.js`/`public/sw.js`, or a PWA plugin already configured in the build config (`@serwist/next`, `next-pwa`, `vite-plugin-pwa`, etc.).
- **Icons** — an existing icon set at or near the 192×192/512×512 sizes Step 1 needs, even if not maskable yet.
- **A root layout or entry point** to extend — for a framework app this already exists (the root layout, `_app`, `index.html`) and needs the manifest link and SW registration *added to it*, not a fresh one created over it.
- **An offline fallback page**, or something close enough to adapt (an existing 404/error page).
For anything that already exists: read it and work with what's there — complete missing manifest fields, add a missing icon size, wire registration into the existing layout — rather than overwriting or duplicating it. If what's there is incomplete or conflicts with what's needed (a manifest missing `display`/`icons`, a service worker with no offline fallback), extend or fix it in place; don't silently replace a working file wholesale without saying so. If it's genuinely ambiguous whether something should be replaced versus extended, ask rather than guess. Only generate from scratch whatever genuinely isn't there yet — the Deliverables Checklist below is what to confirm is present and correct, not a mandate to create all five items unconditionally.
## Deliverables Checklist
Confirm each of these is present and correct, adapted to whichever case above actually applies — creating only whatever the check above found genuinely missing:
- [ ] `index.html` (static/SPA) **or** the framework's own root layout/entry point (Next.js: no `index.html` exists — skip this) — links the manifest, registers the service worker
- [ ] `manifest.json` (static/SPA) **or** the framework's native manifest convention (Next.js App Router: `app/manifest.ts`) — full app metadata and icon set
- [ ] `sw.js` — hand-written (static/SPA) **or** generated by the framework's PWA plugin (Next.js: via `@serwist/next`, not hand-authored) — install, activate, and fetch handlers
- [ ] `app.js` (static/SPA) **or** a client component (framework) — SW registration and install prompt handling
- [ ] `offline.html` — Fallback page shown when navigation fails offline (required — missing file will cause install to fail)
---
## Step 1: Web App Manifest (`manifest.json`)
Defines how the app appears when installed. Must be linked from `<head>` via `<link rel="manifest">`.
```json
{
"name": "My Awesome PWA",
"short_name": "MyPWA",
"description": "A fast, offline-capable Progressive Web App.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#0055ff",
"icons": [
{
"src": "/assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [
{
"src": "/assets/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}
```
**Key fields:**
- `display`: `standalone` hides browser UI; `minimal-ui` shows minimal controls; `browser` is standard tab.
- `purpose: "maskable"` on icons enables adaptive icons on Android (safe zone matters — keep content in center 80%).
- `screenshots` is optional but required for Chrome's enhanced install dialog on desktop.
---
## Step 2: HTML Shell (`index.html`)
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Awesome PWA</title>
<!-- PWA manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Theme color for browser chrome -->
<meta name="theme-color" content="#0055ff">
<!-- iOS-specific (Safari doesn't fully use manifest) -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="MyPWA">
<link rel="apple-touch-icon" href="/assets/icons/icon-192x192.png">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div id="app">
<header><h1>My PWA</h1></header>
<main id="content">Loading...</main>
<!-- Optional: install button, hidden by default -->
<button id="install-btn" hidden>Install App</button>
</div>
<script src="/app.js"></script>
</body>
</html>
```
---
## Step 3: Service Worker Registration & Install Prompt (`app.js`)
```javascript
// ─── Service Worker Registration ───────────────────────────────────────────
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js');
console.log('[App] SW registered, scope:', registration.scope);
} catch (err) {
console.error('[App] SW registration failed:', err);
}
});
}
// ─── Install Prompt (Add to Home Screen) ───────────────────────────────────
let deferredPrompt;
const installBtn = document.getElementById('install-btn'); // may be null if omitted
// Capture the browser's install prompt — it fires before the browser's own UI
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // Stop automatic mini-infobar on mobile
deferredPrompt = e;
if (installBtn) installBtn.hidden = false; // Show your custom install button
});
if (installBtn) {
installBtn.addEventListener('click', async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('[App] Install outcome:', outcome);
deferredPrompt = null;
installBtn.hidden = true;
});
}
// Fires when the app is installed (via browser or your button)
window.addEventListener('appinstalled', () => {
console.log('[App] PWA installed successfully');
installBtn.hidden = true;
});
```
---
## Step 4: Service Worker (`sw.js`)
### Cache Versioning (critical — always increment on deploy)
```javascript
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
// Files to pre-cache during install (the "App Shell")
const APP_SHELL = [
'/',
'/index.html',
'/styles.css',
'/app.js',
'/assets/icons/icon-192x192.png',
'/offline.html', // Fallback page shown when network is unavailable
];
```
### Install — Pre-cache the App Shell
```javascript
self.addEventListener('install', (event) => {
console.log('[SW] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
console.log('[SW] Pre-caching app shell');
return cache.addAll(APP_SHELL);
})
);
// Activate immediately without waiting for old SW to die
self.skipWaiting();
});
```
### Activate — Clean Up Old Caches
```javascript
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== STATIC_CACHE && name !== DYNAMIC_CACHE)
.map((name) => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
);
// Take control of all pages immediately
self.clients.claim();
});
```
### Fetch — Caching Strategies
Choose the right strategy per resource type:
```javascript
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Only handle GET requests from our own origin
if (request.method !== 'GET' || url.origin !== location.origin) return;
// Strategy A: Cache-First (for static assets — fast, tolerates stale)
if (url.pathname.match(/\.(css|js|png|jpg|svg|woff2)$/)) {
event.respondWith(cacheFirst(request));
return;
}
// Strategy B: Network-First (for HTML pages — fresh, falls back to cache)
if (request.headers.get('Accept')?.includes('text/html')) {
event.respondWith(networkFirst(request));
return;
}
// Strategy C: Stale-While-Revalidate (for API data — fast and eventually fresh)
if (url.pathname.startsWith('/api/')) {
event.respondWith(staleWhileRevalidate(request));
return;
}
});
// ─── Strategy Implementations ──────────────────────────────────────────────
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
const cache = await caches.open(STATIC_CACHE);
cache.put(request, response.clone());
return response;
} catch {
// Nothing useful to fall back to for assets
return new Response('Asset unavailable offline', { status: 503 });
}
}
async function networkFirst(request) {
try {
const response = await fetch(request);
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, response.clone());
return response;
} catch {
const cached = await caches.match(request);
return cached || caches.match('/offline.html');
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(DYNAMIC_CACHE);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
cache.put(request, response.clone());
return response;
});
return cached || fetchPromise;
}
```
---
## Edge Cases & Platform Notes
### iOS / Safari Quirks
- Safari supports manifests and service workers but **does not support `beforeinstallprompt`** — users must install via the Share → "Add to Home Screen" menu manually.
- Use the `apple-mobile-web-app-*` meta tags (shown in `index.html` above) for proper iOS integration.
- Safari may clear service worker caches after ~7 days of inactivity (Intelligent Tracking Prevention).
### HTTPS Requirement
- Service workers only register on `https://` origins. `http://localhost` is the only exception for development.
- Use a tool like `mkcert` or `ngrok` if you need HTTPS locally with a custom hostname — or, on Next.js, its own dev server flag for a self-signed local certificate, no extra tool required.
### Service Worker Response Headers
- Serve `sw.js` with an explicit `Content-Type` of `application/javascript`, a `no-cache`/`must-revalidate` `Cache-Control` (so the browser always re-checks for a new one rather than serving a stale cached copy of the service worker file itself — a different problem from the app-shell cache versioning above), and a restrictive `Content-Security-Policy` scoped to the service worker's own script.
- Pair this with the general security headers any app should set — `X-Content-Type-Options: nosniff`, `X-Frame-Options`, `Referrer-Policy` — configured wherever this project already sets HTTP headers (a framework's own headers config, e.g. Next.js's `next.config` `headers()` function, or the static host's config if there's no server-side framework to configure).
### Cache-Busting on Deploy
- Always increment `CACHE_VERSION` in `sw.js` when deploying new assets. This ensures activate clears old caches and users get fresh files.
- A common pattern is to inject the version automatically via your build tool (e.g., Vite, Webpack).
### Opaque Responses (cross-origin requests)
- Requests to external origins (e.g., CDN fonts, third-party APIs) return "opaque" responses that cannot be inspected. Cache them with caution — a failed opaque response still gets a `200` status.
- Prefer `staleWhileRevalidate` for cross-origin resources, or use a library like Workbox which handles this safely.
---
## Workbox (Optional: Production Shortcut)
For production apps, consider [Workbox](https://developer.chrome.com/docs/workbox) (Google's PWA library) instead of hand-rolling strategies. It handles edge cases, cache expiry, and versioning automatically.
```javascript
// With Workbox (via CDN for simplicity — use npm + bundler in production)
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
const { registerRoute } = workbox.routing;
const { CacheFirst, NetworkFirst, StaleWhileRevalidate } = workbox.strategies;
const { precacheAndRoute } = workbox.precaching;
precacheAndRoute(self.__WB_MANIFEST || []); // Injected by build plugin
registerRoute(({ request }) => request.destination === 'image', new CacheFirst());
registerRoute(({ request }) => request.mode === 'navigate', new NetworkFirst());
registerRoute(({ request }) => request.destination === 'script', new StaleWhileRevalidate());
```
---
## Optional: Web Push Notifications
Not every PWA needs this — add it only when the user actually wants re-engagement notifications, not as a mandatory step alongside installability and offline support.
**What it needs, at a high level:**
- A VAPID key pair, generated once (the `web-push` npm package's CLI can generate one). The public key ships to the client; the private key stays server-side only. Both go in environment variables, never committed.
- A client-side subscribe flow: confirm `'serviceWorker' in navigator && 'PushManager' in window`, get the service worker registration, call its `pushManager.subscribe()` with the public VAPID key, then send the resulting subscription object to the backend to persist — a real database row, not an in-memory variable that a server restart would lose.
- A backend endpoint that actually sends the notification, using a push library (e.g. `web-push`) with the stored subscription and the VAPID private key.
- Two service worker event handlers: `push` (build the notification content and call `self.registration.showNotification()`) and `notificationclick` (close the notification and navigate the user somewhere relevant).
**Where it lives in a monorepo:** the subscribe/unsubscribe/send logic belongs in whichever package actually owns the backend — in a NestJS + Next.js monorepo (see Monorepo Placement above), that's the NestJS API, not a Next.js-side server function, since the backend is already the system of record for that kind of state.
**Platform support:** all major browsers now, including iOS 16.4+ — but only for an app that's actually been installed to the home screen on iOS. Confirm installability (Steps 1-3 above) works before layering push on top of it.
**Don't rely on `beforeinstallprompt` for the primary install path.** It only fires on Chromium-based browsers — Safari, including iOS, never fires it at all. A custom "Install" button built on it is a nice-to-have for Chromium users, not a cross-platform mechanism; the OS-level install prompt and the iOS manual Share-sheet flow (see iOS/Safari Quirks above) are what most users actually see. Hide any custom install button once `window.matchMedia('(display-mode: standalone)').matches` is true, so it doesn't linger after the app is already installed.
**Framework note:** for a build-pipeline framework using Server Actions or a similar server-side function to handle the subscribe/send logic, remember that a static export build has no server to run them in — that logic has to move to a real external API endpoint if the project ever switches to static export (see Before You Start above).
---
## Checklist Before Shipping
- [ ] Site is served over HTTPS
- [ ] `manifest.json` has `name`, `short_name`, `start_url`, `display`, `icons` (192 + 512)
- [ ] Icons have `purpose: "any maskable"`
- [ ] `sw.js` registers without errors in DevTools → Application → Service Workers
- [ ] App shell loads from cache when network is throttled to "Offline" in DevTools
- [ ] `offline.html` fallback is cached and served when navigation fails offline
- [ ] Lighthouse PWA audit passes (Chrome DevTools → Lighthouse tab)
- [ ] Tested on iOS Safari (manual install flow) and Android Chrome (install prompt)
- [ ] For a build-pipeline framework: the generated `sw.js` was inspected after a real production build (not `dev` mode) — precached filenames should match the actual hashed build output, not a hand-written guess
- [ ] In a monorepo: the manifest and service worker were confirmed to live inside the frontend package, not the repo root or the backend package
## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.