mcpbeat

React Pwa

jwynia/react-pwa

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.

22k tokens
context cost
the whole folder, loaded on every use
10
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
128
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/jwynia/agent-skills --skill react-pwa

What comes with it

69 977 bytes besides the instruction
assets/manifest-template.json
assets/sw-template.ts
references/caching-strategies.md
references/push-notifications.md
references/testing-pwas.md
scripts/audit-pwa.ts
scripts/generate-icons.ts
scripts/generate-manifest.ts
scripts/generate-sw-config.ts

The instruction itself

47 sections, as written by the author

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:

  • Install dependencies: npm install -D vite-plugin-pwa
  • Add VitePWA plugin to vite.config.ts
  • Generate icons from source image
  • Build and test: npm run build && npm run preview
  • 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:

  • Ensure registerType: 'autoUpdate' is set
  • Add update prompt component to notify users
  • 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:

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

Issue: Caching API Responses Causes Stale Data

Symptoms: Users see outdated data.

Solution:

  • Use NetworkFirst strategy for dynamic API endpoints
  • Add networkTimeoutSeconds for faster fallback
  • Implement cache versioning with expiration

Issue: Push Notifications Not Working

Symptoms: Subscription succeeds but notifications don't arrive.

Solution:

  • Verify VAPID keys match between frontend and backend
  • Check service worker is active (not waiting)
  • Ensure userVisibleOnly: true in subscription
  • 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
  • frontend-design - Design systems and component styling
  • web-search - Research PWA best practices and browser support

How to use it

Copy the folder

Take jwynia/react-pwa from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.

Install what it needs

The instructions reference npm. Without those the skill loads but fails at the first command.