Build a React Progressive Web App (PWA) with Vite and Workbox: A Complete Guide
Build a fast, installable React Progressive Web App with Vite and Workbox: manifest, caching, offline, updates, and Lighthouse audits.
Image used for representation purposes only.
Why build a Progressive Web App with React?
PWAs blend the reach of the web with the feel of native apps: installable, offline-capable, fast, and re‑engaging. For product teams, that means fewer codebases to maintain and faster shipping. In this tutorial you’ll build a production‑ready React PWA with Vite and Workbox, wire up an app manifest, configure smart caching, add an install experience, and validate everything with Lighthouse.
What you’ll build:
- A Vite + React app enhanced with a service worker
- A web app manifest with icons and display settings
- Runtime caching for APIs and images, precaching for app shell
- An update flow so users get new versions seamlessly
Prerequisites:
- Node.js 18+ and npm or pnpm
- Familiarity with React hooks
Project setup (Vite + React)
Vite is the fastest way to bootstrap a modern React app, and it has first‑class PWA support via the vite-plugin-pwa package.
# create the project
npm create vite@latest react-pwa -- --template react-swc
cd react-pwa
# install deps
npm i
# add the PWA plugin
npm i -D vite-plugin-pwa workbox-window
Run the dev server to confirm everything works:
npm run dev
Add the PWA plugin
Open vite.config.ts (or .js) and register the plugin. This configuration does three critical things:
- Generates a web app manifest
- Injects a service worker with Workbox under the hood
- Precaches your production build and sets up runtime caching
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg'],
manifest: {
name: 'React PWA Starter',
short_name: 'ReactPWA',
description: 'A fast, installable React Progressive Web App',
theme_color: '#0ea5e9',
background_color: '#ffffff',
display: 'standalone',
start_url: '/',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: 'pwa-maskable-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
workbox: {
navigateFallback: '/index.html',
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
runtimeCaching: [
{
urlPattern: ({ request }) => request.destination === 'image',
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: { maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] }
}
},
{
urlPattern: ({ url }) => url.origin.includes('api.example.com'),
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api',
cacheableResponse: { statuses: [0, 200] }
}
}
]
}
})
]
})
A few notes:
- registerType: ‘autoUpdate’ checks for updates in the background and activates them on next load.
- runtimeCaching shows two common strategies: CacheFirst for static images, StaleWhileRevalidate for APIs.
Provide icons and a splash‑friendly color
Add the icon files referenced above to public/ (Vite serves them as root). The easiest way to generate a full suite of maskable and regular icons is pwa-asset-generator.
npx pwa-asset-generator public/logo.png public \
-i ./index.html -m \
--favicon --padding "10%" --background "#ffffff"
Maskable icons let Android crop your icon into different shapes without losing important content.
Register the service worker in the app
vite-plugin-pwa injects registration code when you build for production, but you can also control update UX using workbox-window. Create a small hook to surface updates to users.
// src/useServiceWorker.ts
import { useEffect, useState } from 'react'
import { Workbox } from 'workbox-window'
export function useServiceWorker() {
const [needRefresh, setNeedRefresh] = useState(false)
const [offlineReady, setOfflineReady] = useState(false)
useEffect(() => {
if ('serviceWorker' in navigator && import.meta.env.PROD) {
const wb = new Workbox('/sw.js')
wb.addEventListener('waiting', () => setNeedRefresh(true))
wb.addEventListener('activated', (event) => {
if (!event.isUpdate) setOfflineReady(true)
})
wb.register()
}
}, [])
return { needRefresh, offlineReady }
}
Then display a toast or banner when an update is ready:
// src/App.tsx
import { useServiceWorker } from './useServiceWorker'
export default function App() {
const { needRefresh, offlineReady } = useServiceWorker()
return (
<main>
<h1>React PWA Starter</h1>
{offlineReady && (
<p role='status'>App is ready to work offline 🎉</p>
)}
{needRefresh && (
<button onClick={() => location.reload()}>Update available — Reload</button>
)}
{/* your app UI here */}
</main>
)
}
Note: A more advanced approach calls skipWaiting on the waiting service worker and then reloads.
// example: force activate new SW, then reload
wb.addEventListener('waiting', async () => {
await wb.messageSkipWaiting()
location.reload()
})
Add an install experience
Most browsers will prompt users to install when your PWA meets the criteria (manifest + service worker + served over HTTPS). If you want a custom install button, listen for the beforeinstallprompt event and call prompt at the right moment.
// src/useInstallPrompt.ts
import { useEffect, useState } from 'react'
type BipEvent = Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> }
export function useInstallPrompt() {
const [deferred, setDeferred] = useState<BipEvent | null>(null)
const [installed, setInstalled] = useState(false)
useEffect(() => {
const onBip = (e: Event) => {
e.preventDefault()
setDeferred(e as BipEvent)
}
const onInstalled = () => setInstalled(true)
window.addEventListener('beforeinstallprompt', onBip)
window.addEventListener('appinstalled', onInstalled)
return () => {
window.removeEventListener('beforeinstallprompt', onBip)
window.removeEventListener('appinstalled', onInstalled)
}
}, [])
const install = async () => {
if (!deferred) return
await deferred.prompt()
setDeferred(null)
}
return { canInstall: !!deferred, install, installed }
}
// in App.tsx
import { useInstallPrompt } from './useInstallPrompt'
const { canInstall, install, installed } = useInstallPrompt()
{canInstall && <button onClick={install}>Install app</button>}
{installed && <p>Installed! Find me on your home screen.</p>}
Tip: iOS Safari does not fire beforeinstallprompt. Users add to Home Screen via the Share sheet; ensure your manifest and icons are correct and display: ‘standalone’.
Caching strategies that feel fast
A service worker shines when it chooses the right cache strategy for each resource type. Here are practical defaults that work for most React apps:
- App shell (JS/CSS/HTML): Precache during build. Served from cache instantly; updated on next reload.
- Static images and fonts: CacheFirst with long expiration; they rarely change.
- API reads: StaleWhileRevalidate so users see cached data immediately while you fetch fresh data in the background.
- API writes or authenticated endpoints: NetworkOnly or custom logic; never cache secrets.
- Third‑party scripts: StaleWhileRevalidate or skip; avoid caching analytics beacons.
With Workbox you declare these as runtimeCaching rules (as shown in vite.config.ts). Keep the number of caches modest and name them clearly.
Offline fallback for routes and images
When users lose connectivity, you still want meaningful UI. Two simple patterns:
- HTML navigation fallback: navigateFallback points to index.html so client‑side routing still works offline.
- Placeholder image: route unknown images to an offline placeholder.
// add to workbox.runtimeCaching
{
urlPattern: ({ url, request }) => request.destination === 'image' && url.pathname.startsWith('/products/'),
handler: 'CacheFirst',
options: {
cacheName: 'product-images',
plugins: [
{
// custom plugin to serve a local placeholder when missing
handlerDidError: async () => Response.redirect('/offline-image.png', 302)
}
]
}
}
Place offline-image.png in public/.
Handling data freshness in the UI
When using StaleWhileRevalidate for API reads, show a subtle refresh indicator when new data arrives.
// pseudo-hook for SW-triggered content updates
import { useEffect, useState } from 'react'
export function useDataRefreshed(topic: string) {
const [refreshed, setRefreshed] = useState(false)
useEffect(() => {
navigator.serviceWorker?.addEventListener('message', (e: MessageEvent) => {
if (e.data?.type === 'DATA_REFRESHED' && e.data?.topic === topic) {
setRefreshed(true)
}
})
}, [topic])
return refreshed
}
In your fetch code, postMessage from the service worker once the background update finishes.
Environment, HTTPS, and security
- Always test the PWA in a production build: npm run build then npm run preview. Service workers are only registered in production mode by our config.
- PWAs require HTTPS in production. If you self‑host, enable HTTP/2 and set proper caching headers for static assets.
- Don’t cache authenticated responses or secrets. Use the Vary header and appropriate cache‑control for APIs.
Auditing with Lighthouse
Use Chrome DevTools → Lighthouse to validate:
- PWA: pass installability checks, valid manifest, service worker controls page
- Performance: first contentful paint, speed index, time to interactive
- Best Practices and Accessibility: icons are maskable, color contrast, tap targets
Common fixes:
- Provide 192x192 and 512x512 icons (and maskable) in the manifest
- Set theme_color and background_color to match your brand
- Avoid render‑blocking resources; rely on code‑splitting and async imports
Deployment
Static hosts such as Netlify, Vercel, Cloudflare Pages, and GitHub Pages work great. A minimal Netlify configuration:
- Add a _headers file to set service worker scope if needed
- Ensure clean URLs and SPA fallback to index.html
# netlify.toml
[build]
command = 'npm run build'
publish = 'dist'
[[redirects]]
from = '/*'
to = '/index.html'
status = 200
After deployment, open your site on a real device, then:
- Add to Home Screen (Android: browser menu; iOS: Share → Add to Home Screen)
- Go offline and navigate your app
- Trigger a new deployment to verify the update flow
Versioning and updates without surprises
Users expect silent, reliable updates. With autoUpdate, your app will:
- Download a new service worker in the background when you publish a new build
- Enter the waiting state until all tabs close or you call skipWaiting
- Activate on next load (or immediately if you force it)
Design your UI to notify gently: display an unobtrusive banner with a Refresh button that calls location.reload(). Pair it with a one‑line changelog if you want to increase trust.
Optional enhancements
- Background sync: queue writes while offline and replay when online
- Push notifications: requires a backend to store subscriptions and send messages
- Periodic background sync: fetch fresh content at scheduled intervals (browser support varies)
- Offline analytics: queue beacons and flush later
Each feature increases complexity; add them only when you have a clear user benefit.
Troubleshooting checklist
- Service worker not installing: verify HTTPS and that build files are in dist/. Check console for scope errors.
- App not installable: Lighthouse → PWA tab lists missing criteria (icons, manifest, service worker control).
- Stale content: confirm registerType is ‘autoUpdate’ and your update UI calls reload after skipWaiting.
- API caching misbehaving: ensure your urlPattern matches exactly and the API uses cache‑friendly headers.
Recap and next steps
You now have a fast, installable React PWA that precaches the app shell, caches images and APIs wisely, and updates cleanly. Next, layer on domain logic: optimistic UI for offline edits, background sync for reliability, and push notifications for re‑engagement. Keep an eye on Lighthouse scores with every release and treat your service worker like part of your build pipeline.
Key links to explore next:
- vite-plugin-pwa docs for advanced configuration and injectManifest mode
- Workbox recipes for caching strategies and plugins
- pwa-asset-generator for complete icon sets
With these foundations, your React app will feel native, load instantly, and keep working even when the network doesn’t.
Related Posts
React Lazy Loading and Code Splitting: A Practical Tutorial
Learn React lazy loading and code splitting with Suspense, routes, and bundlers. Includes patterns, pitfalls, and performance tips.
React Server Components Tutorial: Build Faster Apps with Less Client JavaScript
Learn React Server Components with Next.js and React Router: server-first data, client boundaries, caching, mutations, and security in 2026.
React useMemo and useCallback: A Practical Optimization Guide
Practical guide to React’s useMemo and useCallback: when to use them, pitfalls to avoid, patterns, and profiling tips for faster apps.