enhance-pwa — Make It Installable and Offline-Ready
Degree of freedom: MIXED. Strategy and prompt timing [HIGH freedom]; caching rules, Capacitor bypass, and Lighthouse [LOW freedom — run exactly].
A PWA closes the gap between "website" and "app". Users can install it to their home screen, it loads instantly from cache, and it keeps working when the network drops. This skill adds those capabilities to any existing web app without breaking what already works.
How to reason
- Audit — manifest, SW, framework plugin already present
- Choose — caching strategy per asset type; Workbox over raw SW
- Ship — installable manifest + offline fallback + autoUpdate
- Prove — Lighthouse PWA ≥ 90; Capacitor bridge not intercepted
Worked example
Audit: Next.js app; no manifest link; no SW; also ships Capacitor. Choose: Workbox via next-pwa; NetworkFirst APIs; CacheFirst images; skip
capacitor://. Ship:manifest.webmanifest+ 192/512 icons +/offline.html; install prompt after first success. Prove: offline revisit of/works; Lighthouse PWA ≥ 90; native bridge still functions.
Self-critique before reporting
- No stale deploys —
autoUpdate, not silent stale cache - No secrets in cache — auth tokens and sensitive APIs are NetworkOnly
- Offline path — fallback page or cached shell, not a browser error
- Right owner — native Capacitor shell →
mobile-capacitor-platform; confirm bridge viamobile-emulator-test
Phase 0: Audit what already exists [HIGH freedom]
public/manifest.json or public/manifest.webmanifest → existing manifest
public/sw.js or src/sw.ts → existing service worker
vite.config.* → vite-plugin-pwa already configured?
next.config.* → next-pwa already configured?
package.json → workbox-*, vite-plugin-pwa, next-pwa, @vite-pwa/nuxt
Also check the current Lighthouse PWA score via:
// eval after goto
const pwaReady = {
manifest: !!document.querySelector('link[rel="manifest"]'),
sw: 'serviceWorker' in navigator,
https: location.protocol === 'https:' || location.hostname === 'localhost',
};
Phase 1: Research framework-specific PWA tooling [HIGH freedom]
firecrawl:firecrawl_search
{
"query": "<framework> PWA service worker offline 2026 vite-plugin-pwa",
"limit": 3,
"sources": [{ "type": "web" }]
}
Fetch the official docs for the relevant PWA plugin via Context7:
context7:resolve-library-id
{
"libraryName": "vite-plugin-pwa"
}
Phase 2: Web App Manifest [HIGH freedom]
The manifest is what makes the app installable. Create or improve
public/manifest.webmanifest:
{
"name": "Full App Name",
"short_name": "Short Name",
"description": "One sentence about what the app does",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#6366f1",
"orientation": "portrait-primary",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable any" }
],
"screenshots": [
{ "src": "/screenshots/home.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" },
{ "src": "/screenshots/home-mobile.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow" }
]
}
Reference the manifest in <head>:
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#6366f1" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
Icon requirements:
- 192×192 and 512×512 minimum; add 180×180 for Apple Touch Icon
- PNG with transparent background OR maskable (safe zone = inner 80%)
- Generate from a single source SVG using
sharporpwa-asset-generator
Phase 3: Service Worker — caching strategy [HIGH freedom]
Use Workbox (via the framework plugin) rather than writing raw service worker code. Choose the right caching strategy per asset type:
| Asset type | Strategy | Why |
|---|---|---|
| App shell (HTML/JS/CSS) | CacheFirst + revision hash | Fast loads, updated on deploy |
| API data (list/detail) | NetworkFirst with 5s timeout | Fresh data, offline fallback |
| Images | CacheFirst, 30-day expiry | Rarely changes |
| Fonts | CacheFirst, 1-year expiry | Never changes |
| Analytics/telemetry | NetworkOnly | No value in caching |
Vite (vite-plugin-pwa)
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/.*\/api\//,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxAgeSeconds: 60 * 60 * 24 },
networkTimeoutSeconds: 5,
},
},
],
},
manifest: { /* inline or separate file */ },
})
Next.js (next-pwa or built-in)
Next.js 16+ has experimental PWA support. For stable Workbox integration:
npm install @ducanh2912/next-pwa
// next.config.mjs
import withPWA from '@ducanh2912/next-pwa';
export default withPWA({
dest: 'public',
cacheOnFrontEndNav: true,
aggressiveFrontEndNavCaching: true,
reloadOnOnline: true,
})({ /* rest of next config */ });
Capacitor compatibility
If the app also ships as a Capacitor native app, the service worker must not intercept Capacitor's bridge requests. Add to the service worker:
// Do not cache Capacitor bridge calls
if (event.request.url.includes('capacitor://') ||
event.request.url.includes('ionic://')) {
return; // let it pass through
}
Phase 4: Offline page and graceful degradation [HIGH freedom]
When the network is unavailable and a cached response does not exist, show a helpful offline page rather than a browser error:
<!-- public/offline.html -->
<h1>You are offline</h1>
<p>Check your connection and try again. Pages you have visited recently will still load.</p>
<button again</button>
Register it as the fallback in Workbox:
offlineFallback: true,
// or in workbox config:
navigationFallback: '/offline.html',
Phase 5: Install prompt (optional but high-value) [HIGH freedom]
Do not use the browser's default install prompt — it appears at the wrong time.
Instead, intercept beforeinstallprompt and show it when the user has
demonstrated value (e.g. after completing a core action):
// hooks/useInstallPrompt.ts
let deferredPrompt: BeforeInstallPromptEvent | null = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;
});
export function triggerInstallPrompt() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
deferredPrompt.userChoice.then(() => { deferredPrompt = null; });
}
Show a custom banner with clear benefits ("Install for offline access and faster loads"), not just "Add to Home Screen".
Phase 6: Push notifications (if backend supports it) [HIGH freedom]
Only implement push if the app has a genuine reason to send notifications (not just to ask for permission on first load — users will deny that).
async function subscribeToPush(userId: string) {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_KEY!),
});
// Save subscription to backend
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify({ userId, subscription }),
});
}
For the backend, use web-push (Node.js) or your platform's push service.
Phase 7: Verify with Lighthouse (Playwright) [LOW freedom — do not skip]
// Run Lighthouse via eval
// (Playwright does not run Lighthouse natively — use Chrome DevTools Protocol)
const { lhr } = await page.evaluate(() => {
return new Promise((resolve) => {
// Trigger Lighthouse via CDP if available in the test environment
// Otherwise, use the Lighthouse CLI:
// npx lighthouse http://localhost:3000 --output json --output-path ./lh-report.json
});
});
Or run Lighthouse via CLI and check the PWA category score:
npx lighthouse http://localhost:3000 \
--output json --output-path .playwright-mcp/lh-report.json \
--chrome-flags="--headless" 2>&1 | tail -5
Target PWA score: ≥ 90. Key checks:
- Manifest present and installable
- Service worker registered
- Works offline (offline page or cached response)
- HTTPS (localhost counts)
-
viewportmeta tag present - Icons correct size and format
-
start_urlloads while offline
Guardrails
- Service worker caching can break deployments if old caches persist.
Always use
registerType: 'autoUpdate'(Workbox prompts the user to reload when a new version is available) rather than silent background updates that serve stale code. - Never cache auth tokens or sensitive API responses in the service worker.
- Test offline mode manually via DevTools → Network → Offline before shipping.
- Capacitor apps: confirm the Capacitor bridge still works after adding the
service worker by running
mobile-emulator-test.