React PWA: Progressive Web App with Service Workers (2026)
A Progressive Web App brings native-like capabilities to a React app: offline support, installability, push notifications and background sync. This guide covers Web App Manifest setup, Service Worker registration via Workbox, Vite PWA plugin for zero-config setup, caching strategies, offline UI, push notifications, and the install prompt.
Table of Contents
Web App Manifest
// public/manifest.json
{
"name": "My React App",
"short_name": "ReactApp",
"description": "A progressive web app built with React",
"start_url": "/",
"display": "standalone",
"background_color": "#030712",
"theme_color": "#6366f1",
"orientation": "portrait-primary",
"icons": [
{ "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"screenshots": [
{ "src": "/screenshots/desktop.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" },
{ "src": "/screenshots/mobile.png", "sizes": "390x844", "type": "image/png", "form_factor": "narrow" }
],
"categories": ["productivity", "utilities"],
"shortcuts": [
{ "name": "New Task", "url": "/tasks/new", "icons": [{ "src": "/icons/add.png", "sizes": "96x96" }] }
]
}
// index.html
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#6366f1" />
<meta name="mobile-web-app-capable" content="yes" />
<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/icon-192.png" />
Vite PWA Plugin
npm install -D vite-plugin-pwa
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate', // Auto-update SW without user prompt
injectRegister: 'auto', // Inject SW registration into HTML
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.myapp\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 },
networkTimeoutSeconds: 5,
},
},
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'StaleWhileRevalidate',
options: { cacheName: 'google-fonts-cache' },
},
],
},
manifest: {
name: 'My React App',
short_name: 'ReactApp',
theme_color: '#6366f1',
background_color: '#030712',
display: 'standalone',
icons: [
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
],
},
}),
],
})
Workbox Caching Strategies
// Choose the right strategy per resource type
// CacheFirst — static assets (CSS, JS, fonts, images)
// Serves from cache, falls back to network. Fast but stale.
{ handler: 'CacheFirst', options: { cacheName: 'static-assets', expiration: { maxAgeSeconds: 30 * 24 * 60 * 60 } } }
// NetworkFirst — API calls and dynamic HTML
// Tries network first, falls back to cache. Fresh when online.
{ handler: 'NetworkFirst', options: { cacheName: 'api-data', networkTimeoutSeconds: 3 } }
// StaleWhileRevalidate — fonts, avatars, CDN assets
// Returns cached version immediately, updates cache in background.
{ handler: 'StaleWhileRevalidate', options: { cacheName: 'cdn-assets' } }
// NetworkOnly — auth endpoints, payments, mutations
// Never cache. Always requires network.
{ handler: 'NetworkOnly' }
// CacheOnly — offline-only content (pre-cached app shell)
{ handler: 'CacheOnly' }
// Precache the app shell (set by globPatterns in workbox config)
// Workbox auto-generates a precache manifest for all matched files
// including cache-busting revision hashes
Offline UI
// src/hooks/useOnlineStatus.ts
import { useState, useEffect, useSyncExternalStore } from 'react'
function subscribe(callback: () => void) {
window.addEventListener('online', callback)
window.addEventListener('offline', callback)
return () => {
window.removeEventListener('online', callback)
window.removeEventListener('offline', callback)
}
}
export function useOnlineStatus() {
return useSyncExternalStore(
subscribe,
() => navigator.onLine,
() => true // Server snapshot — assume online during SSR
)
}
// OfflineBanner component
function OfflineBanner() {
const isOnline = useOnlineStatus()
if (isOnline) return null
return (
<div role="status" aria-live="polite" style={{
position: 'fixed', top: 0, left: 0, right: 0,
background: '#fbbf24', color: '#1c1917',
padding: '10px 16px', textAlign: 'center',
zIndex: 9999, fontWeight: 600,
}}>
You are offline. Some features may be unavailable.
</div>
)
}
// Handle SW update available
import { useRegisterSW } from 'virtual:pwa-register/react'
function UpdatePrompt() {
const { needRefresh: [needRefresh], updateServiceWorker } = useRegisterSW()
if (!needRefresh) return null
return (
<div style={{ position: 'fixed', bottom: 24, right: 24, background: '#0d1424', border: '1px solid rgba(99,102,241,.3)', borderRadius: 12, padding: 20, zIndex: 9999 }}>
<p style={{ color: '#e2e8f0', margin: '0 0 12px' }}>A new version is available!</p>
<button onClick={() => updateServiceWorker(true)} style={{ background: 'linear-gradient(135deg,#6366f1,#22d3ee)', border: 'none', color: '#fff', borderRadius: 6, padding: '8px 16px', cursor: 'pointer' }}>
Update now
</button>
</div>
)
}
Install Prompt
// src/hooks/useInstallPrompt.ts
import { useState, useEffect } from 'react'
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
export function useInstallPrompt() {
const [prompt, setPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [isInstalled, setIsInstalled] = useState(false)
useEffect(() => {
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true)
return
}
function handler(e: Event) {
e.preventDefault()
setPrompt(e as BeforeInstallPromptEvent)
}
window.addEventListener('beforeinstallprompt', handler)
window.addEventListener('appinstalled', () => setIsInstalled(true))
return () => window.removeEventListener('beforeinstallprompt', handler)
}, [])
async function triggerInstall() {
if (!prompt) return
await prompt.prompt()
const { outcome } = await prompt.userChoice
if (outcome === 'accepted') setIsInstalled(true)
setPrompt(null)
}
return { canInstall: !!prompt && !isInstalled, isInstalled, triggerInstall }
}
// Install button component
function InstallButton() {
const { canInstall, triggerInstall } = useInstallPrompt()
if (!canInstall) return null
return (
<button onClick={triggerInstall} style={{ background: 'linear-gradient(135deg,#6366f1,#22d3ee)', border: 'none', color: '#fff', borderRadius: 50, padding: '10px 20px', cursor: 'pointer' }}>
Install App
</button>
)
}
Push Notifications
// Request permission and subscribe
async function subscribeToPush() {
const permission = await Notification.requestPermission()
if (permission !== 'granted') return null
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.VITE_VAPID_PUBLIC_KEY!),
})
// Send subscription to your server
await fetch('/api/push/subscribe', {
method: 'POST',
body: JSON.stringify(subscription),
headers: { 'Content-Type': 'application/json' },
})
return subscription
}
// In the Service Worker (sw.js / Workbox injects this)
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
data: { url: data.url },
actions: [
{ action: 'open', title: 'Open' },
{ action: 'dismiss', title: 'Dismiss' },
],
})
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
if (event.action === 'open' || !event.action) {
event.waitUntil(clients.openWindow(event.notification.data.url))
}
})
Background Sync
// Queue failed mutations for retry when connection restores
// In the Service Worker
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-offline-mutations') {
event.waitUntil(syncOfflineMutations())
}
})
async function syncOfflineMutations() {
const db = await openDB('offline-queue', 1)
const mutations = await db.getAll('mutations')
for (const mutation of mutations) {
try {
await fetch(mutation.url, { method: mutation.method, body: mutation.body, headers: mutation.headers })
await db.delete('mutations', mutation.id)
} catch {
// Will retry on next sync event
}
}
}
// Register sync from app code
async function queueMutationForSync(mutation: object) {
const db = await openDB('offline-queue', 1)
await db.add('mutations', { ...mutation, id: crypto.randomUUID() })
const registration = await navigator.serviceWorker.ready
await registration.sync.register('sync-offline-mutations')
}