Claude Code Plugins

Community-maintained marketplace

Feedback

Build Progressive Web Apps with React and Vite. This skill should be used when the user asks to "create a PWA", "add offline support", "make app installable", "generate service worker", "configure workbox", "add push notifications", or mentions PWA, progressive web app, or offline-first development. Keywords: PWA, progressive web app, service worker, manifest, offline, installable, workbox, vite-plugin-pwa, React.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md


name: react-pwa description: Build Progressive Web Apps with React and Vite. This skill should be used when the user asks to "create a PWA", "add offline support", "make app installable", "generate service worker", "configure workbox", "add push notifications", or mentions PWA, progressive web app, or offline-first development. Keywords: PWA, progressive web app, service worker, manifest, offline, installable, workbox, vite-plugin-pwa, React. license: MIT compatibility: Requires Deno for scripts. Works with React 18+ and Vite 5+. metadata: author: agent-skills version: "1.0"

React PWA

Build Progressive Web Apps with React and Vite using vite-plugin-pwa. This skill covers the complete PWA lifecycle: manifest configuration, service worker strategies, offline support, installability, and push notifications.

When to Use This Skill

Use when:

  • Creating a new PWA from scratch with React + Vite
  • Converting an existing React app to a PWA
  • Adding offline capabilities to a web application
  • Implementing install prompts and app-like experiences
  • Setting up push notifications
  • Optimizing caching strategies for performance

Don't use when:

  • Building server-rendered apps without client-side caching needs
  • Working with Next.js (use next-pwa instead)
  • Simple static sites without offline requirements

Prerequisites

  • Node.js 18+ and npm/pnpm/yarn
  • Vite 5+ with React template
  • Deno runtime (for skill scripts)
  • Source icon: 512x512 PNG or SVG for icon generation

Quick Start

1. Install Dependencies

npm install -D vite-plugin-pwa workbox-window

2. Configure Vite

// 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',
      includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
      manifest: {
        name: 'My React PWA',
        short_name: 'ReactPWA',
        description: 'A Progressive Web App built with React',
        theme_color: '#ffffff',
        background_color: '#ffffff',
        display: 'standalone',
        icons: [
          { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
        ]
      }
    })
  ]
})

3. Generate Icons

deno run --allow-read --allow-write scripts/generate-icons.ts --input logo.png --output public/

4. Add Update Prompt (Optional)

// src/components/PWAUpdatePrompt.tsx
import { useRegisterSW } from 'virtual:pwa-register/react'

export function PWAUpdatePrompt() {
  const {
    needRefresh: [needRefresh, setNeedRefresh],
    updateServiceWorker
  } = useRegisterSW()

  if (!needRefresh) return null

  return (
    <div className="pwa-toast">
      <span>New content available!</span>
      <button onClick={() => updateServiceWorker(true)}>Reload</button>
      <button onClick={() => setNeedRefresh(false)}>Close</button>
    </div>
  )
}

Instructions

Phase 1: Project Setup

1a. Create New Vite React Project

npm create vite@latest my-pwa -- --template react-ts
cd my-pwa
npm install
npm install -D vite-plugin-pwa workbox-window

1b. Add to Existing Project

npm install -D vite-plugin-pwa workbox-window

Add TypeScript types for virtual modules:

// src/vite-env.d.ts (add to existing file)
/// <reference types="vite-plugin-pwa/client" />

Phase 2: Manifest Configuration

The web app manifest defines how the PWA appears when installed.

Required Fields

{
  "name": "Full Application Name",
  "short_name": "ShortName",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3b82f6",
  "icons": []
}

Display Modes

Mode Description Use Case
fullscreen No browser UI, fills entire screen Games, immersive experiences
standalone App-like, with system UI only Most PWAs
minimal-ui Minimal browser controls Apps needing navigation
browser Standard browser tab Fallback only

Generate Manifest

deno run --allow-read --allow-write scripts/generate-manifest.ts \
  --name "My App" \
  --short-name "App" \
  --theme "#3b82f6" \
  --output public/manifest.webmanifest

Phase 3: Service Worker Strategies

vite-plugin-pwa uses Workbox under the hood. Choose a strategy based on your needs.

Strategy Options

Strategy Behavior Best For
generateSW Auto-generates SW from config Most projects
injectManifest Injects precache into custom SW Custom caching logic

Caching Strategies

CacheFirst - Serve from cache, fall back to network:

runtimeCaching: [
  {
    urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
    handler: 'CacheFirst',
    options: {
      cacheName: 'google-fonts-cache',
      expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 }
    }
  }
]

NetworkFirst - Try network, fall back to cache:

{
  urlPattern: /^https:\/\/api\.example\.com\/.*/i,
  handler: 'NetworkFirst',
  options: {
    cacheName: 'api-cache',
    expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 }
  }
}

StaleWhileRevalidate - Serve cache immediately, update in background:

{
  urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
  handler: 'StaleWhileRevalidate',
  options: {
    cacheName: 'images-cache',
    expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 }
  }
}

Complete Vite Config with Caching

// 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',
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\..*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 }
            }
          },
          {
            urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
            handler: 'CacheFirst',
            options: {
              cacheName: 'images',
              expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 * 30 }
            }
          }
        ]
      },
      manifest: {
        name: 'My React PWA',
        short_name: 'ReactPWA',
        theme_color: '#3b82f6',
        background_color: '#ffffff',
        display: 'standalone',
        icons: [
          { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
          { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
        ]
      }
    })
  ]
})

Phase 4: Offline Support

4a. Offline Fallback Page

Create an offline page that displays when network and cache both fail:

// vite.config.ts - add to VitePWA options
workbox: {
  navigateFallback: '/offline.html',
  navigateFallbackDenylist: [/^\/api/]
}

Create public/offline.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Offline</title>
  <style>
    body { font-family: system-ui; display: flex; align-items: center;
           justify-content: center; min-height: 100vh; margin: 0; }
    .offline { text-align: center; }
  </style>
</head>
<body>
  <div class="offline">
    <h1>You're offline</h1>
    <p>Please check your internet connection and try again.</p>
    <button onclick="window.location.reload()">Retry</button>
  </div>
</body>
</html>

4b. Offline Detection Hook

// src/hooks/useOnlineStatus.ts
import { 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 // SSR fallback
  )
}

Usage:

function App() {
  const isOnline = useOnlineStatus()

  return (
    <div>
      {!isOnline && <Banner>You are offline. Some features may be limited.</Banner>}
      {/* ... */}
    </div>
  )
}

Phase 5: Install Prompt

5a. Install Button Component

// src/components/InstallButton.tsx
import { useState, useEffect } from 'react'

interface BeforeInstallPromptEvent extends Event {
  prompt: () => Promise<void>
  userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}

export function InstallButton() {
  const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
  const [isInstalled, setIsInstalled] = useState(false)

  useEffect(() => {
    // Check if already installed
    if (window.matchMedia('(display-mode: standalone)').matches) {
      setIsInstalled(true)
      return
    }

    const handler = (e: Event) => {
      e.preventDefault()
      setDeferredPrompt(e as BeforeInstallPromptEvent)
    }

    window.addEventListener('beforeinstallprompt', handler)
    window.addEventListener('appinstalled', () => setIsInstalled(true))

    return () => window.removeEventListener('beforeinstallprompt', handler)
  }, [])

  const handleInstall = async () => {
    if (!deferredPrompt) return

    deferredPrompt.prompt()
    const { outcome } = await deferredPrompt.userChoice

    if (outcome === 'accepted') {
      setDeferredPrompt(null)
    }
  }

  if (isInstalled || !deferredPrompt) return null

  return (
    <button onClick={handleInstall} className="install-btn">
      Install App
    </button>
  )
}

Phase 6: Push Notifications

6a. Request Permission

async function requestNotificationPermission(): Promise<boolean> {
  if (!('Notification' in window)) {
    console.warn('Notifications not supported')
    return false
  }

  if (Notification.permission === 'granted') return true
  if (Notification.permission === 'denied') return false

  const permission = await Notification.requestPermission()
  return permission === 'granted'
}

6b. Subscribe to Push

async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
  const registration = await navigator.serviceWorker.ready

  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
  })

  // Send subscription to your backend
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  })

  return subscription
}

function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - base64String.length % 4) % 4)
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
  const rawData = window.atob(base64)
  return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
}

6c. Handle Push in Service Worker (injectManifest mode)

// src/sw.ts
import { precacheAndRoute } from 'workbox-precaching'

declare const self: ServiceWorkerGlobalScope

precacheAndRoute(self.__WB_MANIFEST)

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? { title: 'Notification', body: 'New update available' }

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/pwa-192x192.png',
      badge: '/badge-72x72.png'
    })
  )
})

self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  event.waitUntil(
    self.clients.openWindow('/')
  )
})

Examples

Example 1: Basic PWA Setup

Scenario: Convert a Vite React app to an installable PWA with offline support.

Steps:

  1. Install dependencies: npm install -D vite-plugin-pwa
  2. Add VitePWA plugin to vite.config.ts
  3. Generate icons from source image
  4. Build and test: npm run build && npm run preview
  5. Verify in Chrome DevTools > Application > Manifest

Verification:

  • Lighthouse PWA audit passes
  • App is installable (install icon in address bar)
  • Works offline after first load

Example 2: API-Heavy App with Smart Caching

Scenario: E-commerce app with product API calls that should work offline after browsing.

VitePWA({
  workbox: {
    runtimeCaching: [
      // Cache product images aggressively
      {
        urlPattern: /\/products\/images\/.*/,
        handler: 'CacheFirst',
        options: {
          cacheName: 'product-images',
          expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7 }
        }
      },
      // Cache API responses with network-first
      {
        urlPattern: /\/api\/products\/.*/,
        handler: 'NetworkFirst',
        options: {
          cacheName: 'product-api',
          expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 },
          networkTimeoutSeconds: 3
        }
      }
    ]
  }
})

Example 3: Periodic Background Sync

Scenario: News app that syncs content in background.

// Register periodic sync
const registration = await navigator.serviceWorker.ready
await registration.periodicSync.register('sync-articles', {
  minInterval: 60 * 60 * 1000 // 1 hour
})

// Handle in service worker
self.addEventListener('periodicsync', (event) => {
  if (event.tag === 'sync-articles') {
    event.waitUntil(syncArticles())
  }
})

Script Reference

Script Purpose Permissions
generate-manifest.ts Create manifest.webmanifest --allow-read --allow-write
generate-icons.ts Generate icon set from source --allow-read --allow-write --allow-run
generate-sw-config.ts Create service worker config --allow-read --allow-write
audit-pwa.ts Validate PWA compliance --allow-read --allow-net

Generate Manifest

deno run --allow-read --allow-write scripts/generate-manifest.ts \
  --name "My Application" \
  --short-name "MyApp" \
  --description "A great PWA" \
  --theme "#3b82f6" \
  --background "#ffffff" \
  --display standalone \
  --output public/manifest.webmanifest

Generate Icons

deno run --allow-read --allow-write --allow-run scripts/generate-icons.ts \
  --input logo.svg \
  --output public/ \
  --sizes 192,512 \
  --maskable

Audit PWA

deno run --allow-read --allow-net scripts/audit-pwa.ts \
  --url http://localhost:5173 \
  --format summary

Common Issues

Issue: Service Worker Not Updating

Symptoms: Old content served after deployment.

Solution:

  1. Ensure registerType: 'autoUpdate' is set
  2. Add update prompt component to notify users
  3. For immediate updates in development: Chrome DevTools > Application > Service Workers > Update on reload

Issue: App Not Installable

Symptoms: No install prompt, Lighthouse fails installability.

Solution:

  1. Verify manifest has all required fields (name, short_name, icons, start_url, display)
  2. Ensure icons are at least 192x192 and 512x512
  3. Serve over HTTPS (or localhost for development)
  4. Check for service worker registration errors in console

Issue: Caching API Responses Causes Stale Data

Symptoms: Users see outdated data.

Solution:

  1. Use NetworkFirst strategy for dynamic API endpoints
  2. Add networkTimeoutSeconds for faster fallback
  3. Implement cache versioning with expiration

Issue: Push Notifications Not Working

Symptoms: Subscription succeeds but notifications don't arrive.

Solution:

  1. Verify VAPID keys match between frontend and backend
  2. Check service worker is active (not waiting)
  3. Ensure userVisibleOnly: true in subscription
  4. Test push delivery with web-push CLI tool

Additional Resources

Reference Files

  • references/caching-strategies.md - Deep dive into Workbox caching patterns
  • references/push-notifications.md - Complete push notification backend setup
  • references/testing-pwas.md - Testing strategies for PWA features

Assets

  • assets/manifest-template.json - Complete manifest with all optional fields
  • assets/sw-template.ts - Custom service worker template for injectManifest

Limitations

  • Push notifications require a backend server with web-push
  • Background sync has limited browser support (Chrome/Edge)
  • iOS Safari has PWA limitations (no push notifications, limited storage)
  • Service worker debugging can be complex

Related Skills

  • frontend-design - Design systems and component styling
  • web-search - Research PWA best practices and browser support