
Pwa Expert
- 361 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
pwa-expert is an agent skill that helps developers implement production-grade Progressive Web Apps with service workers, offline caching, install prompts, push notifications, and update flows for web apps that must feel
About
pwa-expert is an Erich Owens Claude skill for building installable, offline-capable web applications with Service Workers and web app manifests. It documents four installability requirements (HTTPS, manifest fields, service worker fetch handler, 192×192 and 512×512 icons), five caching strategies (cache-first, network-first, stale-while-revalidate, network-only, cache-only), and TypeScript patterns for registration, `beforeinstallprompt`, background sync, and update detection. The skill ships six reference guides covering service-worker patterns, install prompts, offline handling, background sync, update flow, and Next.js integration. Use it when adding manifest.json, sw.js, Workbox, or next-pwa to a React or Next.js app that must install on mobile and survive offline.
- Service worker caching strategies
- Web app manifest and install UX
- Offline and background sync patterns
- Push notification setup guidance
- Lighthouse-oriented performance budgets
Pwa Expert by the numbers
- 361 all-time installs (skills.sh)
- Ranked #690 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill pwa-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 361 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
How do you add offline caching to a web app?
Implement production-grade PWAs with service workers, offline caching, install prompts, push notifications, and performance budgets for web apps that must feel native on mobile.
Who is it for?
Frontend developers shipping React or Next.js apps that need installable PWAs with service workers, Workbox patterns, and mobile-native behavior.
Skip if: React Native or Flutter native apps, server-only performance tuning without client service workers, or static sites where PWA overhead exceeds benefit.
When should I use this skill?
The user mentions PWA, service worker, manifest.json, Workbox, offline mode, beforeinstallprompt, or background sync for a web application.
What you get
Web app manifest, registered service worker, caching strategy, install prompt handler, offline page, and update notification flow.
- manifest.json
- service worker
- install prompt UI
By the numbers
- Documents 5 service worker caching strategies
- Bundles 6 reference markdown guides for PWA subtopics
Files
Progressive Web App Expert
Build installable, offline-capable web apps with Service Workers, smart caching, and native-like experiences.
When to Use This Skill
- Making a web app installable on mobile/desktop
- Implementing offline functionality
- Setting up Service Worker caching strategies
- Handling install prompts (
beforeinstallprompt) - Background sync for offline-first apps
- Managing PWA update flows
- Creating web app manifests
When NOT to Use This Skill
- Native app development → Use React Native, Flutter, or native SDKs
- General web performance → Use Lighthouse/performance auditing tools
- Server-side rendering issues → Use Next.js/framework-specific docs
- Push notifications only → Consider dedicated push notification services
- Simple static sites → PWA overhead may not be worth it
Core Concepts
What Makes a PWA Installable
1. HTTPS (or localhost for dev) 2. Web App Manifest with required fields 3. Service Worker with fetch handler 4. Icons (192×192 and 512×512 minimum)
The PWA Stack
┌─────────────────────────────────────────┐
│ Your App (React/Next.js) │
├─────────────────────────────────────────┤
│ Service Worker (sw.js) │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ Cache │ │ Network Fetch │ │
│ │ Storage │ │ Handling │ │
│ └─────────────┘ └─────────────────┘ │
├─────────────────────────────────────────┤
│ manifest.json │
│ (App identity, icons, display mode) │
└─────────────────────────────────────────┘Web App Manifest
Complete manifest.json
{
"name": "Junkie Buds 4 Life",
"short_name": "JB4L",
"description": "Recovery support app",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#1a1410",
"theme_color": "#1a1410",
"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/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Find Meetings",
"short_name": "Meetings",
"url": "/meetings?source=shortcut",
"icons": [{ "src": "/icons/meetings-96.png", "sizes": "96x96" }]
}
]
}Display Modes
| Mode | Description |
|---|---|
fullscreen | No browser UI, full screen |
standalone | App-like, no URL bar (recommended) |
minimal-ui | Some browser controls |
browser | Normal browser tab |
Link in HTML
<head>
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1a1410" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
</head>Service Worker Basics
Registration
// lib/pwa.ts
export async function registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
return registration;
} catch (error) {
console.error('SW registration failed:', error);
}
}
}
// Call on app mount
useEffect(() => {
registerServiceWorker();
}, []);Basic Service Worker Structure
// public/sw.js
const CACHE_NAME = 'myapp-v1';
const STATIC_ASSETS = ['/', '/offline', '/manifest.json'];
// Install: Cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
// Activate: Clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
// Fetch: Handle requests (see references for strategies)
self.addEventListener('fetch', (event) => {
event.respondWith(handleFetch(event.request));
});See: references/service-worker-patterns.md for caching strategy implementationsCaching Strategies
| Strategy | Best For | Tradeoff |
|---|---|---|
| Cache-First | Static assets, fonts, images | Stale until cache updated |
| Network-First | API data, user content | Slower, needs connectivity |
| Stale-While-Revalidate | Balance freshness/speed | Background updates |
| Network-Only | Auth, real-time data | No offline support |
| Cache-Only | Versioned assets | Never updates |
See: references/service-worker-patterns.md for full implementationsInstall Prompts
Handle the beforeinstallprompt event to show a custom install UI:
// Basic pattern
const [deferredPrompt, setDeferredPrompt] = useState(null);
useEffect(() => {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
setDeferredPrompt(e);
});
}, []);
const handleInstall = async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
// outcome: 'accepted' or 'dismissed'
}
};See:references/install-prompt.mdfor fullusePWAInstallhook and component
Offline Experience
Key patterns:
- Offline page fallback for navigation failures
useOnlineStatushook to detect connectivity- Offline banner to inform users
See: references/offline-handling.md for implementationsBackground Sync
Queue actions while offline, execute when connectivity returns:
// In Service Worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(syncPendingData());
}
});
// In App - trigger sync
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-data');See: references/background-sync.md for full IndexedDB integrationUpdate Flow
Notify users when a new version is available:
// Basic pattern
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available - show update prompt
}
});
});See:references/update-flow.mdforusePWAUpdatehook and update strategies
Next.js Integration
Options for Next.js PWA:
1. next-pwa - Works with standard Next.js server 2. Custom SW - Required for output: 'export' (static sites) 3. Workbox CLI - Generate SW after build
See: references/nextjs-integration.md for detailed configurationsQuick Reference
| Task | Solution |
|---|---|
| Check if installed | window.matchMedia('(display-mode: standalone)').matches |
| Force SW update | registration.update() |
| Clear all caches | caches.keys().then(keys => keys.forEach(k => caches.delete(k))) |
| Check online | navigator.onLine |
| Get SW registration | navigator.serviceWorker.ready |
| Skip waiting | self.skipWaiting() in SW |
| Take control | self.clients.claim() in SW |
Testing PWA
Chrome DevTools
1. Application tab → Manifest, Service Workers, Cache Storage 2. Lighthouse → PWA audit 3. Network → Offline checkbox to simulate
Debug Checklist
- [ ] Manifest loads (Application → Manifest)
- [ ] SW registered (Application → Service Workers)
- [ ] Cache populated (Application → Cache Storage)
- [ ] Install prompt fires (Console for beforeinstallprompt)
- [ ] Offline page works (Network → Offline)
- [ ] Update flow works (trigger update, verify prompt)
References
Detailed implementations in /references/:
service-worker-patterns.md- Caching strategy implementationsinstall-prompt.md-usePWAInstallhook and install componentoffline-handling.md- Offline page, status hooks, bannersbackground-sync.md- Background sync with IndexedDBupdate-flow.md- Update detection and user promptsnextjs-integration.md- Next.js PWA configuration options
Background Sync
Background Sync allows queuing actions while offline and executing them when connectivity returns.
Service Worker Sync Handler
// In service worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-checkins') {
event.waitUntil(syncCheckins());
}
});
async function syncCheckins() {
const db = await openDB();
const pendingCheckins = await db.getAll('pending-checkins');
for (const checkin of pendingCheckins) {
try {
await fetch('/api/checkins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(checkin),
});
await db.delete('pending-checkins', checkin.id);
} catch (error) {
// Will retry on next sync
console.error('Sync failed:', error);
}
}
}Register Sync from App
// When saving data offline
async function saveCheckin(data: CheckinData) {
// Save to IndexedDB first
await db.add('pending-checkins', { ...data, id: crypto.randomUUID() });
// Request background sync
if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('sync-checkins');
}
}IndexedDB Helper
// lib/db.ts
import { openDB as idbOpen } from 'idb';
export async function openDB() {
return idbOpen('jb4l-offline', 1, {
upgrade(db) {
db.createObjectStore('pending-checkins', { keyPath: 'id' });
db.createObjectStore('pending-journal', { keyPath: 'id' });
},
});
}Sync with Retry Logic
async function syncWithRetry(items, endpoint, storeName, maxRetries = 3) {
const db = await openDB();
for (const item of items) {
let attempts = item.attempts || 0;
try {
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data),
});
// Success - remove from queue
await db.delete(storeName, item.id);
} catch (error) {
attempts++;
if (attempts >= maxRetries) {
// Move to failed queue for manual review
await db.put('failed-syncs', { ...item, attempts, error: error.message });
await db.delete(storeName, item.id);
} else {
// Update attempt count
await db.put(storeName, { ...item, attempts });
}
}
}
}Browser Support
- Chrome 49+
- Edge 79+
- Firefox: Behind flag
- Safari: Not supported (use periodic manual sync)
Install Prompt Implementation
usePWAInstall Hook
// hooks/usePWAInstall.ts
'use client';
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function usePWAInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstallable, setIsInstallable] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
return;
}
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setIsInstallable(true);
};
const handleAppInstalled = () => {
setIsInstalled(true);
setIsInstallable(false);
setDeferredPrompt(null);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
window.addEventListener('appinstalled', handleAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
const install = async () => {
if (!deferredPrompt) return false;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
setDeferredPrompt(null);
setIsInstallable(false);
return outcome === 'accepted';
};
return { isInstallable, isInstalled, install };
}Install Prompt Component
// components/InstallPrompt.tsx
'use client';
import { usePWAInstall } from '@/hooks/usePWAInstall';
import { useState } from 'react';
export function InstallPrompt() {
const { isInstallable, install } = usePWAInstall();
const [dismissed, setDismissed] = useState(false);
if (!isInstallable || dismissed) return null;
return (
<div className="fixed bottom-20 left-4 right-4 md:left-auto md:right-4 md:w-80
bg-leather-800 border border-leather-600 rounded-lg p-4 shadow-lg
animate-slide-up z-50">
<div className="flex items-start gap-3">
<img src="/icons/icon-64.png" alt="" className="w-12 h-12 rounded-lg" />
<div className="flex-1">
<h3 className="font-bitter text-lg text-leather-100">Install JB4L</h3>
<p className="text-sm text-leather-400 mt-1">
Get quick access and offline support
</p>
</div>
<button
onClick={() => setDismissed(true)}
className="text-leather-500 hover:text-leather-300"
aria-label="Dismiss"
>
✕
</button>
</div>
<div className="flex gap-2 mt-4">
<button
onClick={() => setDismissed(true)}
className="flex-1 px-4 py-2 text-leather-400 hover:text-leather-200"
>
Not now
</button>
<button
onClick={install}
className="flex-1 px-4 py-2 bg-ember-500 hover:bg-ember-600
text-white rounded-lg font-medium"
>
Install
</button>
</div>
</div>
);
}Best Practices
1. Don't show immediately - Wait for user engagement or after they've used the app a few times 2. Respect dismissal - Store in localStorage if user says "Not now" 3. Show value first - Explain benefits: offline access, quick launch, etc. 4. iOS handling - iOS doesn't support beforeinstallprompt, show manual instructions instead
Next.js PWA Integration
Using next-pwa
npm install next-pwanext.config.js Configuration
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 3600 },
},
},
{
urlPattern: /\.(png|jpg|jpeg|svg|gif|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: { maxEntries: 100, maxAgeSeconds: 86400 * 30 },
},
},
],
});
module.exports = withPWA({
// Your Next.js config
});Static Export (Cloudflare Pages)
For output: 'export', next-pwa can't generate SW at build time. Use manual approach:
1. Create Custom Service Worker
// public/sw.js
const CACHE_NAME = 'myapp-v1';
// ... (use patterns from service-worker-patterns.md)2. Register Manually
// lib/pwa.ts
export async function registerServiceWorker() {
if ('serviceWorker' in navigator) {
try {
await navigator.serviceWorker.register('/sw.js', { scope: '/' });
} catch (error) {
console.error('SW registration failed:', error);
}
}
}3. Call in Layout
// app/layout.tsx
'use client';
import { useEffect } from 'react';
import { registerServiceWorker } from '@/lib/pwa';
export default function RootLayout({ children }) {
useEffect(() => {
registerServiceWorker();
}, []);
return <html>{/* ... */}</html>;
}Workbox CLI Alternative
For more control with static exports:
npm install workbox-cli --save-dev// workbox-config.js
module.exports = {
globDirectory: 'out/',
globPatterns: ['**/*.{html,js,css,png,jpg,svg}'],
swDest: 'out/sw.js',
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./,
handler: 'NetworkFirst',
},
],
};// package.json
{
"scripts": {
"build": "next build && npx workbox generateSW workbox-config.js"
}
}Manifest in Next.js App Router
// app/manifest.ts
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'My App',
short_name: 'App',
start_url: '/',
display: 'standalone',
background_color: '#1a1410',
theme_color: '#1a1410',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
],
};
}Offline Handling
Offline Page
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-leather-950">
<div className="text-center max-w-md">
<div className="text-6xl mb-4">📡</div>
<h1 className="font-bitter text-2xl text-leather-100 mb-2">
You're Offline
</h1>
<p className="text-leather-400 mb-6">
Check your connection and try again. Your saved data is still available.
</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-3 bg-ember-500 hover:bg-ember-600
text-white rounded-lg font-medium"
>
Try Again
</button>
</div>
</div>
);
}useOnlineStatus Hook
// hooks/useOnlineStatus.ts
'use client';
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// Set initial state
setIsOnline(navigator.onLine);
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}Offline Banner Component
// components/OfflineBanner.tsx
'use client';
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
export function OfflineBanner() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div className="fixed top-0 left-0 right-0 bg-amber-600 text-white
py-2 px-4 text-center text-sm z-50">
You're offline. Some features may be unavailable.
</div>
);
}Offline-First Data Pattern
// hooks/useOfflineData.ts
import { useState, useEffect } from 'react';
import { useOnlineStatus } from './useOnlineStatus';
export function useOfflineData<T>(
key: string,
fetcher: () => Promise<T>
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const isOnline = useOnlineStatus();
useEffect(() => {
async function loadData() {
// Try localStorage first
const cached = localStorage.getItem(key);
if (cached) {
setData(JSON.parse(cached));
setLoading(false);
}
// Fetch fresh data if online
if (isOnline) {
try {
const fresh = await fetcher();
setData(fresh);
localStorage.setItem(key, JSON.stringify(fresh));
} catch (error) {
console.error('Fetch failed:', error);
}
}
setLoading(false);
}
loadData();
}, [key, isOnline, fetcher]);
return { data, loading, isOnline };
}Service Worker Patterns
Basic Service Worker Structure
// public/sw.js
const CACHE_NAME = 'jb4l-v1';
const STATIC_ASSETS = [
'/',
'/offline',
'/manifest.json',
'/icons/icon-192.png',
'/icons/icon-512.png',
];
// Install: Cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting(); // Activate immediately
});
// Activate: Clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
);
})
);
self.clients.claim(); // Take control immediately
});
// Fetch: Handle requests
self.addEventListener('fetch', (event) => {
event.respondWith(handleFetch(event.request));
});
async function handleFetch(request) {
// Network-first for API requests
if (request.url.includes('/api/')) {
return networkFirst(request);
}
// Cache-first for static assets
return cacheFirst(request);
}Caching Strategy Implementations
Cache-First
Try cache, fallback to network. Best for static assets.
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
// Cache successful responses
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Return offline page for navigation
if (request.mode === 'navigate') {
return caches.match('/offline');
}
throw error;
}
}Network-First
Try network, fallback to cache. Best for API data.
async function networkFirst(request) {
try {
const response = await fetch(request);
// Cache successful API responses
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch (error) {
const cached = await caches.match(request);
if (cached) return cached;
// Return error response for API
return new Response(
JSON.stringify({ error: 'Offline', cached: false }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
}Stale-While-Revalidate
Return cache immediately, update in background. Best balance of speed and freshness.
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
});
return cached || fetchPromise;
}Route-Based Strategy Selection
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') return;
// API routes: Network-first with cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets: Cache-first
if (url.pathname.match(/\.(js|css|png|jpg|svg|woff2?)$/)) {
event.respondWith(cacheFirst(request));
return;
}
// HTML pages: Stale-while-revalidate
if (request.mode === 'navigate') {
event.respondWith(staleWhileRevalidate(request));
return;
}
// Default: Network-first
event.respondWith(networkFirst(request));
});Handle Skip Waiting Message
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});PWA Update Flow
usePWAUpdate Hook
// hooks/usePWAUpdate.ts
'use client';
import { useState, useEffect } from 'react';
export function usePWAUpdate() {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [registration, setRegistration] = useState<ServiceWorkerRegistration | null>(null);
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((reg) => {
setRegistration(reg);
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
setUpdateAvailable(true);
}
});
});
});
}
}, []);
const applyUpdate = () => {
if (registration?.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
};
return { updateAvailable, applyUpdate };
}Update Banner Component
// components/UpdateBanner.tsx
'use client';
import { usePWAUpdate } from '@/hooks/usePWAUpdate';
export function UpdateBanner() {
const { updateAvailable, applyUpdate } = usePWAUpdate();
if (!updateAvailable) return null;
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-80
bg-ember-600 text-white rounded-lg p-4 shadow-lg z-50">
<p className="font-medium mb-2">Update Available</p>
<p className="text-sm opacity-90 mb-3">
A new version is ready. Reload to get the latest features.
</p>
<button
onClick={applyUpdate}
className="w-full py-2 bg-white text-ember-600 rounded font-medium
hover:bg-gray-100 transition-colors"
>
Reload Now
</button>
</div>
);
}Service Worker Message Handler
// In sw.js
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});Check for Updates Manually
// Force check for updates
async function checkForUpdates() {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
await registration.update();
}
}
// Call periodically or on user action
useEffect(() => {
const interval = setInterval(checkForUpdates, 60 * 60 * 1000); // Every hour
return () => clearInterval(interval);
}, []);Update Strategies
1. Immediate Update (Aggressive)
// In sw.js - takes control immediately
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
self.clients.claim();
});2. User-Prompted Update (Recommended)
Wait for user to click "Update" before reloading. Less disruptive.
3. Background Update
Update on next visit. User gets new version automatically.
// Only skipWaiting when no active clients
self.addEventListener('install', async (event) => {
const clients = await self.clients.matchAll();
if (clients.length === 0) {
self.skipWaiting();
}
});Related skills
How it compares
Use pwa-expert for full PWA architecture; use Lighthouse or performance-only skills when you only need audits without implementing service workers.
FAQ
What makes a web app installable according to pwa-expert?
pwa-expert lists four requirements: HTTPS (or localhost in development), a web app manifest with required fields, a service worker with a fetch handler, and icons at minimum 192×192 and 512×512 pixels including maskable variants when needed.
Does pwa-expert cover Next.js PWA setup?
Yes. pwa-expert documents Next.js integration options including next-pwa for standard servers, custom service workers for static `output: export` builds, and Workbox CLI generation, with details in its nextjs-integration reference guide.