Progressive Web Apps (PWAs) combine the reach of the web with native app capabilities. This guide covers building modern PWAs with offline support and native features.
Core PWA Requirements#
Web App Manifest#
1// public/manifest.json
2{
3 "name": "My Progressive App",
4 "short_name": "MyApp",
5 "description": "A fast, reliable web application",
6 "start_url": "/",
7 "display": "standalone",
8 "background_color": "#ffffff",
9 "theme_color": "#2563eb",
10 "orientation": "portrait-primary",
11 "icons": [
12 {
13 "src": "/icons/icon-192.png",
14 "sizes": "192x192",
15 "type": "image/png",
16 "purpose": "any maskable"
17 },
18 {
19 "src": "/icons/icon-512.png",
20 "sizes": "512x512",
21 "type": "image/png"
22 }
23 ],
24 "screenshots": [
25 {
26 "src": "/screenshots/desktop.png",
27 "sizes": "1280x720",
28 "type": "image/png",
29 "form_factor": "wide"
30 },
31 {
32 "src": "/screenshots/mobile.png",
33 "sizes": "750x1334",
34 "type": "image/png",
35 "form_factor": "narrow"
36 }
37 ],
38 "shortcuts": [
39 {
40 "name": "New Task",
41 "url": "/tasks/new",
42 "icons": [{ "src": "/icons/new-task.png", "sizes": "96x96" }]
43 }
44 ],
45 "share_target": {
46 "action": "/share",
47 "method": "POST",
48 "enctype": "multipart/form-data",
49 "params": {
50 "title": "title",
51 "text": "text",
52 "url": "url"
53 }
54 }
55}Service Worker Registration#
1// app/layout.tsx
2'use client';
3
4import { useEffect } from 'react';
5
6export function ServiceWorkerRegistration() {
7 useEffect(() => {
8 if ('serviceWorker' in navigator) {
9 navigator.serviceWorker
10 .register('/sw.js')
11 .then((registration) => {
12 console.log('SW registered:', registration.scope);
13
14 // Check for updates
15 registration.addEventListener('updatefound', () => {
16 const newWorker = registration.installing;
17 newWorker?.addEventListener('statechange', () => {
18 if (newWorker.state === 'installed' &&
19 navigator.serviceWorker.controller) {
20 // New version available
21 showUpdateNotification();
22 }
23 });
24 });
25 })
26 .catch((error) => {
27 console.error('SW registration failed:', error);
28 });
29 }
30 }, []);
31
32 return null;
33}Service Worker Implementation#
Caching Strategies#
1// public/sw.js
2const CACHE_NAME = 'app-v1';
3const STATIC_ASSETS = [
4 '/',
5 '/offline',
6 '/styles/main.css',
7 '/scripts/app.js',
8];
9
10// Install: Cache static assets
11self.addEventListener('install', (event) => {
12 event.waitUntil(
13 caches.open(CACHE_NAME).then((cache) => {
14 return cache.addAll(STATIC_ASSETS);
15 })
16 );
17 self.skipWaiting();
18});
19
20// Activate: Clean old caches
21self.addEventListener('activate', (event) => {
22 event.waitUntil(
23 caches.keys().then((keys) => {
24 return Promise.all(
25 keys
26 .filter((key) => key !== CACHE_NAME)
27 .map((key) => caches.delete(key))
28 );
29 })
30 );
31 self.clients.claim();
32});
33
34// Fetch: Handle requests
35self.addEventListener('fetch', (event) => {
36 const { request } = event;
37 const url = new URL(request.url);
38
39 // API requests: Network first
40 if (url.pathname.startsWith('/api/')) {
41 event.respondWith(networkFirst(request));
42 return;
43 }
44
45 // Static assets: Cache first
46 if (request.destination === 'image' ||
47 request.destination === 'style' ||
48 request.destination === 'script') {
49 event.respondWith(cacheFirst(request));
50 return;
51 }
52
53 // HTML: Stale while revalidate
54 event.respondWith(staleWhileRevalidate(request));
55});
56
57async function cacheFirst(request) {
58 const cached = await caches.match(request);
59 if (cached) return cached;
60
61 const response = await fetch(request);
62 const cache = await caches.open(CACHE_NAME);
63 cache.put(request, response.clone());
64 return response;
65}
66
67async function networkFirst(request) {
68 try {
69 const response = await fetch(request);
70 const cache = await caches.open(CACHE_NAME);
71 cache.put(request, response.clone());
72 return response;
73 } catch (error) {
74 const cached = await caches.match(request);
75 return cached || new Response('Offline', { status: 503 });
76 }
77}
78
79async function staleWhileRevalidate(request) {
80 const cache = await caches.open(CACHE_NAME);
81 const cached = await cache.match(request);
82
83 const fetchPromise = fetch(request).then((response) => {
84 cache.put(request, response.clone());
85 return response;
86 }).catch(() => cached || caches.match('/offline'));
87
88 return cached || fetchPromise;
89}Push Notifications#
Subscribing to Push#
1async function subscribeToPush() {
2 const registration = await navigator.serviceWorker.ready;
3
4 const subscription = await registration.pushManager.subscribe({
5 userVisibleOnly: true,
6 applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
7 });
8
9 // Send subscription to server
10 await fetch('/api/push/subscribe', {
11 method: 'POST',
12 headers: { 'Content-Type': 'application/json' },
13 body: JSON.stringify(subscription),
14 });
15
16 return subscription;
17}
18
19function urlBase64ToUint8Array(base64String: string) {
20 const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
21 const base64 = (base64String + padding)
22 .replace(/-/g, '+')
23 .replace(/_/g, '/');
24 const rawData = window.atob(base64);
25 return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
26}Handling Push Events#
1// sw.js
2self.addEventListener('push', (event) => {
3 const data = event.data?.json() || {};
4
5 const options = {
6 body: data.body,
7 icon: '/icons/notification.png',
8 badge: '/icons/badge.png',
9 vibrate: [100, 50, 100],
10 data: {
11 url: data.url || '/',
12 },
13 actions: [
14 { action: 'view', title: 'View' },
15 { action: 'dismiss', title: 'Dismiss' },
16 ],
17 };
18
19 event.waitUntil(
20 self.registration.showNotification(data.title || 'Notification', options)
21 );
22});
23
24self.addEventListener('notificationclick', (event) => {
25 event.notification.close();
26
27 if (event.action === 'view' || !event.action) {
28 const url = event.notification.data.url;
29 event.waitUntil(
30 clients.matchAll({ type: 'window' }).then((windowClients) => {
31 // Focus existing window or open new
32 for (const client of windowClients) {
33 if (client.url === url && 'focus' in client) {
34 return client.focus();
35 }
36 }
37 return clients.openWindow(url);
38 })
39 );
40 }
41});Background Sync#
1// Register background sync
2async function saveForLater(data) {
3 // Store in IndexedDB
4 await idb.put('pendingSync', data);
5
6 // Register sync
7 const registration = await navigator.serviceWorker.ready;
8 await registration.sync.register('sync-data');
9}
10
11// Handle sync in service worker
12self.addEventListener('sync', (event) => {
13 if (event.tag === 'sync-data') {
14 event.waitUntil(syncPendingData());
15 }
16});
17
18async function syncPendingData() {
19 const pending = await idb.getAll('pendingSync');
20
21 for (const item of pending) {
22 try {
23 await fetch('/api/sync', {
24 method: 'POST',
25 body: JSON.stringify(item),
26 });
27 await idb.delete('pendingSync', item.id);
28 } catch (error) {
29 // Will retry next time
30 throw error;
31 }
32 }
33}Native Features#
Share API#
1async function shareContent(data: ShareData) {
2 if (navigator.canShare?.(data)) {
3 try {
4 await navigator.share(data);
5 return true;
6 } catch (error) {
7 if (error.name !== 'AbortError') {
8 console.error('Share failed:', error);
9 }
10 }
11 }
12 return false;
13}
14
15// Usage
16shareContent({
17 title: 'Check this out',
18 text: 'Amazing content',
19 url: window.location.href,
20});File System Access#
1async function saveFile(content: string, filename: string) {
2 if ('showSaveFilePicker' in window) {
3 const handle = await window.showSaveFilePicker({
4 suggestedName: filename,
5 types: [{
6 description: 'Text files',
7 accept: { 'text/plain': ['.txt'] },
8 }],
9 });
10
11 const writable = await handle.createWritable();
12 await writable.write(content);
13 await writable.close();
14 } else {
15 // Fallback to download
16 const blob = new Blob([content], { type: 'text/plain' });
17 const url = URL.createObjectURL(blob);
18 const a = document.createElement('a');
19 a.href = url;
20 a.download = filename;
21 a.click();
22 URL.revokeObjectURL(url);
23 }
24}Install Prompt#
1import { useState, useEffect } from 'react';
2
3export function InstallPrompt() {
4 const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
5 const [showPrompt, setShowPrompt] = useState(false);
6
7 useEffect(() => {
8 const handler = (e: Event) => {
9 e.preventDefault();
10 setDeferredPrompt(e);
11 setShowPrompt(true);
12 };
13
14 window.addEventListener('beforeinstallprompt', handler);
15 return () => window.removeEventListener('beforeinstallprompt', handler);
16 }, []);
17
18 const handleInstall = async () => {
19 if (!deferredPrompt) return;
20
21 deferredPrompt.prompt();
22 const { outcome } = await deferredPrompt.userChoice;
23
24 if (outcome === 'accepted') {
25 setShowPrompt(false);
26 }
27 setDeferredPrompt(null);
28 };
29
30 if (!showPrompt) return null;
31
32 return (
33 <div className="fixed bottom-4 left-4 right-4 bg-white shadow-lg rounded-lg p-4">
34 <p className="font-medium">Install our app for the best experience</p>
35 <div className="mt-2 flex gap-2">
36 <button onClick={handleInstall} className="btn-primary">
37 Install
38 </button>
39 <button onClick={() => setShowPrompt(false)} className="btn-secondary">
40 Not now
41 </button>
42 </div>
43 </div>
44 );
45}Best Practices#
- Test offline mode: Ensure core functionality works offline
- Optimize asset caching: Cache only what's needed
- Handle update gracefully: Notify users of new versions
- Provide feedback: Show sync status and offline indicators
- Respect battery/data: Use background sync sparingly
Conclusion#
PWAs provide native-like experiences on the web. Start with the basics—manifest and service worker—then progressively add features like push notifications and background sync.