Creating a PWA with React + Vite
Introduction
Progressive Web Apps (PWAs) provide native app-like experiences in browsers with offline capabilities, push notifications, and installability. This guide shows how to build one with React and Vite.
Prerequisites
- React project setup
- Basic understanding of service workers
Step 1: Install PWA Plugin
npm install vite-plugin-pwa workbox-window
Step 2: Configure Vite for PWA
Update 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: "prompt",
includeAssets: ["favicon.ico", "apple-touch-icon.png", "masked-icon.svg"],
manifest: {
name: "My React PWA",
short_name: "ReactPWA",
description: "A React Progressive Web App built with Vite",
theme_color: "#ffffff",
background_color: "#ffffff",
display: "standalone",
scope: "/",
start_url: "/",
icons: [
{
src: "pwa-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png",
},
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "api-cache",
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 365 days
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
}),
],
});
Step 3: Create SW Registration Hook
Create hooks/usePWA.ts
:
import { useEffect, useState } from "react";
import { useRegisterSW } from "virtual:pwa-register/react";
interface PWAState {
needRefresh: boolean;
offlineReady: boolean;
updateAvailable: boolean;
}
export function usePWA() {
const [pwaState, setPwaState] = useState<PWAState>({
needRefresh: false,
offlineReady: false,
updateAvailable: false,
});
const { needRefresh, offlineReady, updateServiceWorker } = useRegisterSW({
onRegistered(r) {
console.log("SW Registered: " + r);
},
onRegisterError(error) {
console.log("SW registration error", error);
},
onNeedRefresh() {
setPwaState(prev => ({
...prev,
needRefresh: true,
updateAvailable: true,
}));
},
onOfflineReady() {
setPwaState(prev => ({
...prev,
offlineReady: true,
}));
},
});
const updateApp = () => {
updateServiceWorker(true);
};
const dismissUpdate = () => {
setPwaState(prev => ({
...prev,
needRefresh: false,
updateAvailable: false,
}));
};
return {
...pwaState,
updateApp,
dismissUpdate,
};
}
Step 4: Create Update Notification Component
Create components/PWAUpdateNotification.tsx
:
import { usePWA } from '@/hooks/usePWA';
export default function PWAUpdateNotification() {
const { needRefresh, offlineReady, updateApp, dismissUpdate } = usePWA();
if (!needRefresh && !offlineReady) return null;
return (
<div className="fixed bottom-4 right-4 z-50">
{offlineReady && (
<div className="bg-green-500 text-white p-4 rounded-lg shadow-lg mb-2">
<p className="font-medium">App ready to work offline!</p>
<button
onClick={dismissUpdate}
className="mt-2 text-sm underline hover:no-underline"
>
Dismiss
</button>
</div>
)}
{needRefresh && (
<div className="bg-blue-500 text-white p-4 rounded-lg shadow-lg">
<p className="font-medium">New content available!</p>
<p className="text-sm opacity-90 mb-3">
Click reload to update to the latest version.
</p>
<div className="flex space-x-2">
<button
onClick={updateApp}
className="bg-white text-blue-500 px-4 py-2 rounded text-sm font-medium hover:bg-gray-100"
>
Reload
</button>
<button
onClick={dismissUpdate}
className="bg-blue-600 px-4 py-2 rounded text-sm hover:bg-blue-700"
>
Later
</button>
</div>
</div>
)}
</div>
);
}
Step 5: Install App Component
Create components/InstallPWA.tsx
:
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export default function InstallPWA() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstallable, setIsInstallable] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
}
const handleBeforeInstallPrompt = (e: BeforeInstallPromptEvent) => {
// Prevent the mini-infobar from appearing on mobile
e.preventDefault();
setDeferredPrompt(e);
setIsInstallable(true);
};
const handleAppInstalled = () => {
setIsInstalled(true);
setIsInstallable(false);
setDeferredPrompt(null);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt as any);
window.addEventListener('appinstalled', handleAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt as any);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
const handleInstallClick = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
console.log('User accepted the install prompt');
} else {
console.log('User dismissed the install prompt');
}
setDeferredPrompt(null);
setIsInstallable(false);
};
if (isInstalled) {
return (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-green-800">
App Installed Successfully!
</h3>
<p className="text-sm text-green-700">
You can now use this app offline.
</p>
</div>
</div>
</div>
);
}
if (!isInstallable) return null;
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-blue-800">
Install App
</h3>
<p className="text-sm text-blue-700 mb-3">
Install this app on your device for a better experience and offline access.
</p>
<button
onClick={handleInstallClick}
className="bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Install App
</button>
</div>
</div>
</div>
);
}
Step 6: Offline Status Component
Create components/OfflineStatus.tsx
:
import { useState, useEffect } from 'react';
export default function OfflineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
if (isOnline) return null;
return (
<div className="fixed top-0 left-0 right-0 bg-yellow-500 text-white p-2 text-center z-50">
<div className="flex items-center justify-center">
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
You're currently offline. Some features may be limited.
</div>
</div>
);
}
Step 7: Create Main App Component
Update App.tsx
:
import { Suspense } from 'react';
import PWAUpdateNotification from './components/PWAUpdateNotification';
import InstallPWA from './components/InstallPWA';
import OfflineStatus from './components/OfflineStatus';
function App() {
return (
<div className="min-h-screen bg-gray-50">
<OfflineStatus />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4">
<h1 className="text-3xl font-bold text-gray-900">My PWA App</h1>
</div>
</header>
<main className="max-w-7xl mx-auto py-6 px-4">
<div className="mb-6">
<InstallPWA />
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<h3 className="text-lg font-medium text-gray-900">Offline Ready</h3>
<p className="mt-1 text-sm text-gray-500">
This app works even when you're offline.
</p>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<h3 className="text-lg font-medium text-gray-900">Installable</h3>
<p className="mt-1 text-sm text-gray-500">
Add to your home screen for quick access.
</p>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<h3 className="text-lg font-medium text-gray-900">Fast Loading</h3>
<p className="mt-1 text-sm text-gray-500">
Cached resources for instant loading.
</p>
</div>
</div>
</div>
</main>
<PWAUpdateNotification />
</div>
);
}
export default App;
Step 8: Build and Deploy
# Build the PWA
npm run build
# Test the PWA locally
npm run preview
The build will generate:
- Service worker files
- Web app manifest
- Optimized assets with caching strategies
Summary
Creating a PWA with React and Vite enables offline functionality, app installation, and native-like experiences. The vite-plugin-pwa
handles service worker generation and manifest configuration automatically.