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.

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')
}