Back to Journal
Mobile/Frontend

How to Build Progressive Web Apps Using React

Step-by-step tutorial for building Progressive Web Apps with React, from project setup through deployment.

Muneer Puthiya Purayil 23 min read

React provides the UI foundation for many Progressive Web Apps, but the PWA capabilities — service workers, caching, offline support, and installability — require careful integration beyond what Create React App or Vite offer out of the box. This tutorial builds a production-ready PWA with React, Vite, and TypeScript.

Project Setup with Vite

bash
1npm create vite@latest my-react-pwa -- --template react-ts
2cd my-react-pwa
3npm install
4npm install -D vite-plugin-pwa workbox-window
5 

Configure the Vite PWA plugin:

typescript
1// vite.config.ts
2import { defineConfig } from 'vite';
3import react from '@vitejs/plugin-react';
4import { VitePWA } from 'vite-plugin-pwa';
5 
6export default defineConfig({
7 plugins: [
8 react(),
9 VitePWA({
10 registerType: 'prompt',
11 includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
12 manifest: {
13 name: 'My React PWA',
14 short_name: 'ReactPWA',
15 description: 'A Progressive Web App built with React',
16 theme_color: '#1a1a2e',
17 background_color: '#ffffff',
18 display: 'standalone',
19 start_url: '/',
20 icons: [
21 { src: '/icons/pwa-192x192.png', sizes: '192x192', type: 'image/png' },
22 { src: '/icons/pwa-512x512.png', sizes: '512x512', type: 'image/png' },
23 { src: '/icons/pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
24 ],
25 },
26 workbox: {
27 globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
28 runtimeCaching: [
29 {
30 urlPattern: /^https:\/\/api\.example\.com\/.*/i,
31 handler: 'NetworkFirst',
32 options: {
33 cacheName: 'api-cache',
34 networkTimeoutSeconds: 5,
35 cacheableResponse: { statuses: [0, 200] },
36 expiration: { maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 },
37 },
38 },
39 {
40 urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
41 handler: 'CacheFirst',
42 options: {
43 cacheName: 'image-cache',
44 expiration: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 },
45 },
46 },
47 ],
48 },
49 }),
50 ],
51});
52 

Service Worker Registration with Update Prompt

Create a hook that manages service worker lifecycle:

typescript
1// src/hooks/useServiceWorker.ts
2import { useState, useEffect, useCallback } from 'react';
3import { registerSW } from 'virtual:pwa-register';
4 
5interface ServiceWorkerState {
6 needRefresh: boolean;
7 offlineReady: boolean;
8 updateServiceWorker: () => Promise<void>;
9 close: () => void;
10}
11 
12export function useServiceWorker(): ServiceWorkerState {
13 const [needRefresh, setNeedRefresh] = useState(false);
14 const [offlineReady, setOfflineReady] = useState(false);
15 const [updateSW, setUpdateSW] = useState<(() => Promise<void>) | null>(null);
16 
17 useEffect(() => {
18 const update = registerSW({
19 onNeedRefresh() {
20 setNeedRefresh(true);
21 },
22 onOfflineReady() {
23 setOfflineReady(true);
24 },
25 onRegisteredSW(swUrl, registration) {
26 if (registration) {
27 // Check for updates every hour
28 setInterval(() => {
29 registration.update();
30 }, 60 * 60 * 1000);
31 }
32 },
33 });
34 setUpdateSW(() => update);
35 }, []);
36 
37 const updateServiceWorker = useCallback(async () => {
38 if (updateSW) await updateSW(true);
39 }, [updateSW]);
40 
41 const close = useCallback(() => {
42 setNeedRefresh(false);
43 setOfflineReady(false);
44 }, []);
45 
46 return { needRefresh, offlineReady, updateServiceWorker, close };
47}
48 

Update Prompt Component

typescript
1// src/components/UpdatePrompt.tsx
2import { useServiceWorker } from '../hooks/useServiceWorker';
3 
4export function UpdatePrompt() {
5 const { needRefresh, offlineReady, updateServiceWorker, close } = useServiceWorker();
6 
7 if (!needRefresh && !offlineReady) return null;
8 
9 return (
10 <div className="fixed bottom-4 right-4 z-50 max-w-sm rounded-lg bg-white p-4 shadow-xl border">
11 {offlineReady && (
12 <div>
13 <p className="font-medium text-gray-900">App ready for offline use</p>
14 <p className="mt-1 text-sm text-gray-500">
15 Content has been cached for offline access.
16 </p>
17 <button
18 onClick={close}
19 className="mt-3 text-sm text-blue-600 hover:text-blue-800"
20 >
21 Dismiss
22 </button>
23 </div>
24 )}
25 
26 {needRefresh && (
27 <div>
28 <p className="font-medium text-gray-900">Update available</p>
29 <p className="mt-1 text-sm text-gray-500">
30 A new version is available. Reload to update.
31 </p>
32 <div className="mt-3 flex gap-3">
33 <button
34 onClick={updateServiceWorker}
35 className="rounded bg-black px-4 py-1.5 text-sm text-white hover:bg-gray-800"
36 >
37 Update now
38 </button>
39 <button
40 onClick={close}
41 className="text-sm text-gray-500 hover:text-gray-700"
42 >
43 Later
44 </button>
45 </div>
46 </div>
47 )}
48 </div>
49 );
50}
51 

Install Prompt Hook

typescript
1// src/hooks/useInstallPrompt.ts
2import { useState, useEffect, useCallback } from 'react';
3 
4interface BeforeInstallPromptEvent extends Event {
5 prompt(): Promise<void>;
6 userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
7}
8 
9export function useInstallPrompt() {
10 const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
11 const [isInstallable, setIsInstallable] = useState(false);
12 const [isInstalled, setIsInstalled] = useState(false);
13 
14 useEffect(() => {
15 // Check if already installed
16 if (window.matchMedia('(display-mode: standalone)').matches) {
17 setIsInstalled(true);
18 return;
19 }
20 
21 const handleBeforeInstall = (e: Event) => {
22 e.preventDefault();
23 setDeferredPrompt(e as BeforeInstallPromptEvent);
24 setIsInstallable(true);
25 };
26 
27 const handleInstalled = () => {
28 setIsInstalled(true);
29 setIsInstallable(false);
30 setDeferredPrompt(null);
31 };
32 
33 window.addEventListener('beforeinstallprompt', handleBeforeInstall);
34 window.addEventListener('appinstalled', handleInstalled);
35 
36 return () => {
37 window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
38 window.removeEventListener('appinstalled', handleInstalled);
39 };
40 }, []);
41 
42 const install = useCallback(async (): Promise<boolean> => {
43 if (!deferredPrompt) return false;
44 deferredPrompt.prompt();
45 const { outcome } = await deferredPrompt.userChoice;
46 setDeferredPrompt(null);
47 return outcome === 'accepted';
48 }, [deferredPrompt]);
49 
50 return { isInstallable, isInstalled, install };
51}
52 

Offline-Capable Data Hook

typescript
1// src/hooks/useOfflineQuery.ts
2import { useState, useEffect, useCallback, useRef } from 'react';
3 
4interface UseOfflineQueryOptions {
5 cacheKey: string;
6 maxAge?: number; // ms
7}
8 
9interface OfflineQueryResult<T> {
10 data: T | null;
11 isLoading: boolean;
12 isOffline: boolean;
13 isStale: boolean;
14 error: Error | null;
15 refetch: () => Promise<void>;
16}
17 
18const dbPromise = openDatabase();
19 
20function openDatabase(): Promise<IDBDatabase> {
21 return new Promise((resolve, reject) => {
22 const request = indexedDB.open('react-pwa-cache', 1);
23 request.onupgradeneeded = () => {
24 const db = request.result;
25 if (!db.objectStoreNames.contains('cache')) {
26 db.createObjectStore('cache', { keyPath: 'key' });
27 }
28 };
29 request.onsuccess = () => resolve(request.result);
30 request.onerror = () => reject(request.error);
31 });
32}
33 
34export function useOfflineQuery<T>(
35 url: string,
36 options: UseOfflineQueryOptions,
37): OfflineQueryResult<T> {
38 const [data, setData] = useState<T | null>(null);
39 const [isLoading, setIsLoading] = useState(true);
40 const [isOffline, setIsOffline] = useState(!navigator.onLine);
41 const [isStale, setIsStale] = useState(false);
42 const [error, setError] = useState<Error | null>(null);
43 const abortRef = useRef<AbortController | null>(null);
44 
45 const fetchData = useCallback(async () => {
46 setIsLoading(true);
47 setError(null);
48 
49 abortRef.current?.abort();
50 abortRef.current = new AbortController();
51 
52 try {
53 const response = await fetch(url, { signal: abortRef.current.signal });
54 if (!response.ok) throw new Error(`HTTP ${response.status}`);
55 
56 const freshData = await response.json();
57 setData(freshData);
58 setIsOffline(false);
59 setIsStale(false);
60 
61 // Update cache
62 const db = await dbPromise;
63 const tx = db.transaction('cache', 'readwrite');
64 tx.objectStore('cache').put({
65 key: options.cacheKey,
66 value: freshData,
67 timestamp: Date.now(),
68 });
69 } catch (err) {
70 if ((err as Error).name === 'AbortError') return;
71 
72 // Try cache fallback
73 try {
74 const db = await dbPromise;
75 const tx = db.transaction('cache', 'readonly');
76 const cached = await new Promise<any>((resolve, reject) => {
77 const req = tx.objectStore('cache').get(options.cacheKey);
78 req.onsuccess = () => resolve(req.result);
79 req.onerror = () => reject(req.error);
80 });
81 
82 if (cached) {
83 setData(cached.value);
84 setIsOffline(true);
85 const age = Date.now() - cached.timestamp;
86 setIsStale(options.maxAge ? age > options.maxAge : false);
87 } else {
88 setError(err as Error);
89 }
90 } catch {
91 setError(err as Error);
92 }
93 } finally {
94 setIsLoading(false);
95 }
96 }, [url, options.cacheKey, options.maxAge]);
97 
98 useEffect(() => {
99 fetchData();
100 return () => abortRef.current?.abort();
101 }, [fetchData]);
102 
103 useEffect(() => {
104 const handleOnline = () => {
105 setIsOffline(false);
106 fetchData();
107 };
108 const handleOffline = () => setIsOffline(true);
109 
110 window.addEventListener('online', handleOnline);
111 window.addEventListener('offline', handleOffline);
112 return () => {
113 window.removeEventListener('online', handleOnline);
114 window.removeEventListener('offline', handleOffline);
115 };
116 }, [fetchData]);
117 
118 return { data, isLoading, isOffline, isStale, error, refetch: fetchData };
119}
120 

Network Status Indicator

typescript
1// src/components/NetworkStatus.tsx
2import { useState, useEffect } from 'react';
3 
4export function NetworkStatus() {
5 const [isOnline, setIsOnline] = useState(navigator.onLine);
6 const [showBanner, setShowBanner] = useState(false);
7 
8 useEffect(() => {
9 const handleOnline = () => {
10 setIsOnline(true);
11 setShowBanner(true);
12 setTimeout(() => setShowBanner(false), 3000);
13 };
14 const handleOffline = () => {
15 setIsOnline(false);
16 setShowBanner(true);
17 };
18 
19 window.addEventListener('online', handleOnline);
20 window.addEventListener('offline', handleOffline);
21 
22 return () => {
23 window.removeEventListener('online', handleOnline);
24 window.removeEventListener('offline', handleOffline);
25 };
26 }, []);
27 
28 if (!showBanner) return null;
29 
30 return (
31 <div
32 className={`fixed top-0 left-0 right-0 z-50 py-2 text-center text-sm font-medium transition-transform ${
33 isOnline
34 ? 'bg-green-500 text-white'
35 : 'bg-amber-500 text-white'
36 }`}
37 >
38 {isOnline ? 'Back online — syncing data...' : 'You are offline — changes will sync when connected'}
39 </div>
40 );
41}
42 

Need a second opinion on your mobile/frontend architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Offline Form Queue

typescript
1// src/hooks/useOfflineForm.ts
2import { useState, useCallback } from 'react';
3 
4interface QueueItem {
5 id: string;
6 url: string;
7 data: Record<string, unknown>;
8 timestamp: number;
9}
10 
11export function useOfflineForm(url: string) {
12 const [status, setStatus] = useState<'idle' | 'submitting' | 'submitted' | 'queued' | 'error'>('idle');
13 
14 const submit = useCallback(async (data: Record<string, unknown>) => {
15 setStatus('submitting');
16 
17 try {
18 const response = await fetch(url, {
19 method: 'POST',
20 headers: { 'Content-Type': 'application/json' },
21 body: JSON.stringify(data),
22 });
23 
24 if (!response.ok) throw new Error('Submission failed');
25 setStatus('submitted');
26 return true;
27 } catch {
28 // Queue for offline sync
29 const item: QueueItem = {
30 id: crypto.randomUUID(),
31 url,
32 data,
33 timestamp: Date.now(),
34 };
35 
36 const db = await openFormDB();
37 const tx = db.transaction('queue', 'readwrite');
38 tx.objectStore('queue').add(item);
39 
40 // Register background sync
41 if ('serviceWorker' in navigator) {
42 const reg = await navigator.serviceWorker.ready;
43 try {
44 await (reg as any).sync.register('form-sync');
45 } catch {
46 // Background sync not supported — will process on next online event
47 }
48 }
49 
50 setStatus('queued');
51 return false;
52 }
53 }, [url]);
54 
55 return { submit, status };
56}
57 
58function openFormDB(): Promise<IDBDatabase> {
59 return new Promise((resolve, reject) => {
60 const request = indexedDB.open('form-queue', 1);
61 request.onupgradeneeded = () => {
62 request.result.createObjectStore('queue', { keyPath: 'id' });
63 };
64 request.onsuccess = () => resolve(request.result);
65 request.onerror = () => reject(request.error);
66 });
67}
68 

Push Notification Hook

typescript
1// src/hooks/usePushNotifications.ts
2import { useState, useCallback } from 'react';
3 
4function urlBase64ToUint8Array(base64String: string): Uint8Array {
5 const padding = '='.repeat((4 - base64String.length % 4) % 4);
6 const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
7 const rawData = atob(base64);
8 return Uint8Array.from(rawData, (c) => c.charCodeAt(0));
9}
10 
11export function usePushNotifications() {
12 const [isSubscribed, setIsSubscribed] = useState(false);
13 const [isSupported] = useState(() =>
14 'serviceWorker' in navigator && 'PushManager' in window
15 );
16 
17 const subscribe = useCallback(async () => {
18 if (!isSupported) return false;
19 
20 const permission = await Notification.requestPermission();
21 if (permission !== 'granted') return false;
22 
23 const registration = await navigator.serviceWorker.ready;
24 const subscription = await registration.pushManager.subscribe({
25 userVisibleOnly: true,
26 applicationServerKey: urlBase64ToUint8Array(
27 import.meta.env.VITE_VAPID_PUBLIC_KEY
28 ),
29 });
30 
31 await fetch('/api/push/subscribe', {
32 method: 'POST',
33 headers: { 'Content-Type': 'application/json' },
34 body: JSON.stringify(subscription.toJSON()),
35 });
36 
37 setIsSubscribed(true);
38 return true;
39 }, [isSupported]);
40 
41 return { isSubscribed, isSupported, subscribe };
42}
43 

Putting It All Together

typescript
1// src/App.tsx
2import { UpdatePrompt } from './components/UpdatePrompt';
3import { NetworkStatus } from './components/NetworkStatus';
4import { InstallPrompt } from './components/InstallPrompt';
5import { useInstallPrompt } from './hooks/useInstallPrompt';
6 
7function InstallPromptComponent() {
8 const { isInstallable, install } = useInstallPrompt();
9 
10 if (!isInstallable) return null;
11 
12 return (
13 <div className="fixed bottom-4 left-4 right-4 z-40 rounded-lg bg-white p-4 shadow-xl md:left-auto md:w-80">
14 <p className="font-medium">Install this app</p>
15 <p className="mt-1 text-sm text-gray-500">
16 Add to your home screen for the best experience.
17 </p>
18 <button
19 onClick={install}
20 className="mt-3 rounded bg-black px-4 py-2 text-sm text-white hover:bg-gray-800"
21 >
22 Install
23 </button>
24 </div>
25 );
26}
27 
28export default function App() {
29 return (
30 <div className="min-h-screen bg-gray-50">
31 <NetworkStatus />
32 <main className="mx-auto max-w-4xl p-6">
33 <h1 className="text-3xl font-bold">My React PWA</h1>
34 {/* Your app content */}
35 </main>
36 <UpdatePrompt />
37 <InstallPromptComponent />
38 </div>
39 );
40}
41 

Testing

typescript
1// src/__tests__/pwa.test.tsx
2import { render, screen, waitFor } from '@testing-library/react';
3import { describe, it, expect, vi, beforeEach } from 'vitest';
4import { NetworkStatus } from '../components/NetworkStatus';
5 
6describe('NetworkStatus', () => {
7 beforeEach(() => {
8 vi.stubGlobal('navigator', { ...navigator, onLine: true });
9 });
10 
11 it('shows offline banner when network drops', async () => {
12 render(<NetworkStatus />);
13 
14 // Simulate going offline
15 Object.defineProperty(navigator, 'onLine', { value: false, writable: true });
16 window.dispatchEvent(new Event('offline'));
17 
18 await waitFor(() => {
19 expect(screen.getByText(/you are offline/i)).toBeTruthy();
20 });
21 });
22 
23 it('shows recovery banner when network returns', async () => {
24 render(<NetworkStatus />);
25 
26 window.dispatchEvent(new Event('offline'));
27 window.dispatchEvent(new Event('online'));
28 
29 await waitFor(() => {
30 expect(screen.getByText(/back online/i)).toBeTruthy();
31 });
32 });
33});
34 

Build and Deploy

bash
1# Build the production PWA
2npm run build
3 
4# Preview locally
5npm run preview
6 
7# Deploy to a static host (Cloudflare Pages, Vercel, Netlify)
8# The dist/ folder contains the complete PWA
9 

Verify the PWA before deploying:

  1. Run Lighthouse audit on the preview URL
  2. Test the install prompt on mobile
  3. Toggle airplane mode and verify offline behavior
  4. Check that the service worker caches all critical assets

Conclusion

Building a PWA with React and Vite is straightforward with vite-plugin-pwa handling service worker generation and caching strategies. The key investment is in the React hooks that bridge PWA browser APIs with your component tree — install prompts, update notifications, offline data, and network status.

The registerType: 'prompt' configuration is critical for production apps. Auto-updating service workers (autoUpdate) can cause jarring page reloads mid-interaction. The prompt-based approach lets users choose when to update, providing a native-app-like experience that respects user workflow.

FAQ

Need expert help?

Building with mobile/frontend?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026