Skip to content
Go back

Creating a PWA with React + Vite

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

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:

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.


Share this post on:

Previous Post
Implementing SSR Caching in Next.js
Next Post
Optimizing React App Performance with Code Splitting