--- name: pwa-development description: Progressive Web App development for installable, offline-capable web applications. Use when building PWAs, implementing service workers, or creating offline-first experiences. --- # Progressive Web App (PWA) Development Build installable, offline-capable web applications that work across all platforms. ## PWA Capabilities | Feature | Support | | ------------------ | --------------------- | | Offline access | All modern browsers | | Install prompt | Chrome, Edge, Samsung | | Push notifications | All except iOS Safari | | Background sync | Chrome, Edge | | File handling | Chrome, Edge | | Share target | Chrome, Edge, Safari | --- ## Core Requirements ### 1. Web App Manifest ```json // public/manifest.json { "name": "My Progressive Web App", "short_name": "MyPWA", "description": "A progressive web application", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#3b82f6", "orientation": "portrait-primary", "scope": "/", "icons": [ { "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" }, { "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" }, { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } ], "screenshots": [ { "src": "/screenshots/desktop.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" }, { "src": "/screenshots/mobile.png", "sizes": "750x1334", "type": "image/png", "form_factor": "narrow" } ], "shortcuts": [ { "name": "New Document", "short_name": "New", "url": "/new", "icons": [{ "src": "/icons/new.png", "sizes": "96x96" }] } ], "share_target": { "action": "/share", "method": "POST", "enctype": "multipart/form-data", "params": { "title": "title", "text": "text", "url": "url", "files": [ { "name": "files", "accept": ["image/*", "text/*"] } ] } } } ``` ### 2. HTML Meta Tags ```html My PWA
``` --- ## Service Worker ### Basic Service Worker ```typescript // public/sw.js const CACHE_NAME = "my-pwa-v1"; const STATIC_ASSETS = [ "/", "/index.html", "/manifest.json", "/icons/icon-192x192.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 - clean old caches self.addEventListener("activate", (event) => { event.waitUntil( caches.keys().then((cacheNames) => { return Promise.all( cacheNames .filter((name) => name !== CACHE_NAME) .map((name) => caches.delete(name)), ); }), ); self.clients.claim(); }); // Fetch - serve from cache, fallback to network self.addEventListener("fetch", (event) => { event.respondWith( caches.match(event.request).then((cached) => { // Cache-first strategy if (cached) { return cached; } // Network fallback return fetch(event.request).then((response) => { // Don't cache non-GET or failed requests if (event.request.method !== "GET" || !response.ok) { return response; } // Cache successful responses const responseClone = response.clone(); caches.open(CACHE_NAME).then((cache) => { cache.put(event.request, responseClone); }); return response; }); }), ); }); ``` ### Workbox (Recommended) ```typescript // src/sw.ts import { precacheAndRoute } from "workbox-precaching"; import { registerRoute } from "workbox-routing"; import { CacheFirst, NetworkFirst, StaleWhileRevalidate, } from "workbox-strategies"; import { ExpirationPlugin } from "workbox-expiration"; // Precache static assets precacheAndRoute(self.__WB_MANIFEST); // Cache images registerRoute( ({ request }) => request.destination === "image", new CacheFirst({ cacheName: "images", plugins: [ new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days }), ], }), ); // Network-first for API calls registerRoute( ({ url }) => url.pathname.startsWith("/api/"), new NetworkFirst({ cacheName: "api-cache", plugins: [ new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 5 * 60, // 5 minutes }), ], }), ); // Stale-while-revalidate for pages registerRoute( ({ request }) => request.mode === "navigate", new StaleWhileRevalidate({ cacheName: "pages", }), ); ``` ### Service Worker Registration ```typescript // src/registerSW.ts export async function registerServiceWorker() { if ("serviceWorker" in navigator) { try { const registration = await navigator.serviceWorker.register("/sw.js", { scope: "/", }); registration.addEventListener("updatefound", () => { const newWorker = registration.installing; if (newWorker) { newWorker.addEventListener("statechange", () => { if (newWorker.state === "installed") { if (navigator.serviceWorker.controller) { // New update available dispatchEvent(new CustomEvent("sw:update")); } } }); } }); console.log("Service Worker registered:", registration.scope); } catch (error) { console.error("Service Worker registration failed:", error); } } } ``` --- ## Install Prompt ### Custom Install Button ```tsx import { useState, useEffect } from "react"; interface BeforeInstallPromptEvent extends Event { prompt: () => Promise; userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; } function InstallButton() { const [deferredPrompt, setDeferredPrompt] = useState(null); const [isInstalled, setIsInstalled] = useState(false); useEffect(() => { // Check if already installed if (window.matchMedia("(display-mode: standalone)").matches) { setIsInstalled(true); return; } const handler = (e: Event) => { e.preventDefault(); setDeferredPrompt(e as BeforeInstallPromptEvent); }; window.addEventListener("beforeinstallprompt", handler); return () => window.removeEventListener("beforeinstallprompt", handler); }, []); const handleInstall = async () => { if (!deferredPrompt) return; await deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; if (outcome === "accepted") { setIsInstalled(true); } setDeferredPrompt(null); }; if (isInstalled || !deferredPrompt) return null; return ( ); } ``` --- ## Offline Support ### Offline Detection ```tsx import { useState, useEffect } from "react"; function useOnlineStatus() { const [isOnline, setIsOnline] = useState(navigator.onLine); useEffect(() => { 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; } // Usage function App() { const isOnline = useOnlineStatus(); return (
{!isOnline && (
You're offline. Some features may be unavailable.
)}
); } ``` ### Background Sync ```typescript // In Service Worker self.addEventListener("sync", (event) => { if (event.tag === "sync-data") { event.waitUntil(syncData()); } }); async function syncData() { const db = await openDB("pending-requests", 1); const requests = await db.getAll("requests"); for (const request of requests) { try { await fetch(request.url, request.options); await db.delete("requests", request.id); } catch { // Will retry on next sync } } } // Register sync from app async function queueRequest(url: string, options: RequestInit) { await navigator.serviceWorker.ready; if ("sync" in window.ServiceWorkerRegistration.prototype) { // Store request and trigger sync const db = await openDB("pending-requests", 1); await db.add("requests", { url, options, id: Date.now() }); await navigator.serviceWorker.ready.then((reg) => reg.sync.register("sync-data"), ); } else { // Fallback to immediate fetch await fetch(url, options); } } ``` --- ## Push Notifications ### Request Permission ```typescript async function requestNotificationPermission() { const permission = await Notification.requestPermission(); if (permission === "granted") { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), }); // Send subscription to server await fetch("/api/push/subscribe", { method: "POST", body: JSON.stringify(subscription), }); } } ``` ### Handle Push in Service Worker ```typescript self.addEventListener("push", (event) => { const data = event.data?.json() ?? {}; event.waitUntil( self.registration.showNotification(data.title, { body: data.body, icon: "/icons/icon-192x192.png", badge: "/icons/badge.png", data: data.url, }), ); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); if (event.notification.data) { event.waitUntil(clients.openWindow(event.notification.data)); } }); ``` --- ## Vite PWA Plugin ```typescript // vite.config.ts import { VitePWA } from "vite-plugin-pwa"; export default defineConfig({ plugins: [ react(), VitePWA({ registerType: "autoUpdate", includeAssets: ["favicon.ico", "robots.txt", "icons/*.png"], manifest: { name: "My PWA", short_name: "MyPWA", theme_color: "#3b82f6", icons: [ // ... icon definitions ], }, workbox: { globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], runtimeCaching: [ { urlPattern: /^https:\/\/api\.example\.com\/.*/i, handler: "NetworkFirst", options: { cacheName: "api-cache", expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24, // 24 hours }, }, }, ], }, }), ], }); ``` --- ## Testing PWA ### Lighthouse Audit 1. Open Chrome DevTools 2. Go to Lighthouse tab 3. Select "Progressive Web App" 4. Run audit ### Required Scores - Performance: 90+ - PWA: 100 - Accessibility: 90+ - Best Practices: 90+ --- ## Best Practices ### DO: - Use HTTPS (required) - Provide offline fallback - Cache static assets - Show update notification - Handle all screen sizes ### DON'T: - Cache everything forever - Block app on SW update - Ignore iOS limitations - Skip manifest icons - Forget offline states