Skip to content

The Edge of the Cyber World See the latest

Apps

React vs Vue PWA Setup: 12 Steps, 100 Min [2026]

Add a manifest, register a service worker, and technically you have a Progressive Web App. Ship it that way and it will fail Lighthouse’s installability check, cache the wrong assets, and leave users staring at a blank screen the first time their connection drops. The gap between “it works on my machine” and a PWA that installs cleanly and survives offline is where most React and Vue teams get stuck in 2026.

This tutorial builds a working, installable, offline-capable PWA twice: once on React 19.2.8 and once on Vue 3.5.41, both scaffolded with Vite and wired up through vite-plugin-pwa. You will configure the manifest, pick a service worker strategy, cache API responses and static assets, handle the install prompt, notify users when a new version ships, and pass a Lighthouse PWA audit. Every step includes the exact code for both frameworks so you can see where the setup is identical and where it genuinely diverges. If you have not scaffolded a side-by-side React and Vue project before, the React vs Vue Vite scaffolding walkthrough covers the base setup this tutorial builds on.

Why build a progressive web app with React or Vue in 2026

A PWA gets you an installable icon on the home screen, offline access to previously visited pages, and background caching, all without touching an app store. For a React or Vue team already shipping a single-page app, the incremental cost is small: a manifest file, a service worker, and a plugin that wires the two into your existing Vite build. You do not rewrite routing, state management, or your component tree.

The tooling has matured enough that this is no longer a weekend research project. vite-plugin-pwa wraps Google’s Workbox library and exposes it through a single Vite config block, so both React and Vue projects reach a working PWA through nearly identical configuration. The differences that remain live in how each framework registers the service worker at runtime, how it renders the install prompt, and how it reacts when a new deployment goes live while a tab is still open.

This matters more now that both ecosystems have stabilized around long-lived major versions. React sits on the 19.2 line, with 19.2.8 shipping July 21, 2026. Vue is on the 3.5 line, with 3.5.41 shipping August 5, 2026. Vue 2 reached end of life on December 31, 2023 at version 2.7.16, and Vue 3.4 lost active support on September 3, 2024, so 3.5 is the only actively supported Vue 3 minor as of this tutorial. Neither framework is mid-migration right now, which makes this a good moment to lock in a PWA setup that will not need a rewrite in six months.

Prerequisites: versions and tools you need

Confirm these versions before you start. Running an older Vite version is the single most common cause of the plugin silently failing to register a service worker, so check this table against your own package.json first.

Tool Version used in this tutorial Minimum required
Node.js 20.20.2 LTS Node.js 20 LTS or newer
React 19.2.8 (released July 21, 2026) 18.0 or newer
Vue 3.5.41 (released August 5, 2026) 3.3 or newer
Vite 8.2.2 5.0 or newer
vite-plugin-pwa 1.3.0 0.19 or newer
workbox-window 7.4.1 7.0 or newer
npm 10.x (bundled with Node 20) 9.0 or newer

You also need a code editor, a terminal, and a browser with DevTools support for Application and Lighthouse panels (Chrome or Edge, both Chromium-based, work best for service worker debugging). Check the React release notes and Vue’s own changelog before you start if you are on an older major version, since this tutorial assumes the versions listed above. You do not need a paid hosting plan. Any static host that serves HTTPS, including Vercel, Netlify, Cloudflare Pages, or a self-managed nginx box with a Let’s Encrypt certificate, will work for the deployment step later in this guide, since service workers refuse to register on plain HTTP outside of localhost.

React vs Vue PWA architecture: where they diverge

Both frameworks reach the same destination through vite-plugin-pwa, but the runtime glue differs in three places: how the service worker gets registered, how the install prompt hooks into component state, and how the plugin’s virtual module gets imported.

Concern React 19.2 Vue 3.5
Service worker registration Manual call to the virtual module inside a top-level useEffect, or the plugin’s auto-injected script Composable from virtual:pwa-register/vue, reactive by default
Install prompt state Plain useState plus a beforeinstallprompt listener ref() plus the same native browser event, wrapped in a composable
Update notification Custom hook wrapping registerSW() callbacks Built-in reactive refs (needRefresh, offlineReady) from the Vue-specific virtual module
Config file vite.config.ts with @vitejs/plugin-react vite.config.ts with @vitejs/plugin-vue
TypeScript ambient types vite-plugin-pwa/react types (community-documented pattern) Official vite-plugin-pwa/vue virtual module with typed refs

The practical takeaway: Vue’s official virtual module ships reactive state out of the box, so the install-prompt and update-notification code is a few lines shorter than the React equivalent, which needs a small custom hook to get the same ergonomics. Neither is meaningfully harder to maintain once it is written, but if you are choosing a framework partly on this basis, Vue’s PWA composable is the more batteries-included option. The same pattern shows up in other cross-framework setups, our React vs Vue data fetching comparison found Vue’s official tooling similarly trims a few lines off equivalent React code.

Step 1: scaffold the React and Vue projects with Vite

Create a parent folder and scaffold both apps with Vite’s official TypeScript templates. Keeping the build tooling identical on both sides removes one variable from the comparison.

mkdir pwa-comparison && cd pwa-comparison

npm create vite@latest react-pwa -- --template react-ts
npm create vite@latest vue-pwa -- --template vue-ts

cd react-pwa && npm install && cd ..
cd vue-pwa && npm install && cd ..

Run npm run dev in each folder to confirm both apps boot on their default ports before you touch anything PWA-related. If either app fails to start here, fix that first. Debugging a broken service worker on top of a broken build is a bad use of an afternoon.

Step 2: install vite-plugin-pwa in both projects

Install the plugin as a dev dependency in each project. It ships as a single package regardless of which framework you use, and it pulls in Workbox internally, so you do not add workbox-build or workbox-window as separate dependencies unless you need direct access to their APIs later.

cd react-pwa
npm install -D vite-plugin-pwa

cd ../vue-pwa
npm install -D vite-plugin-pwa

Next, wire the plugin into each vite.config.ts. This is the React version:

// react-pwa/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'],
      manifest: {
        name: 'React PWA Demo',
        short_name: 'ReactPWA',
        description: 'A React 19.2 progressive web app built with Vite',
        theme_color: '#1e293b',
        background_color: '#ffffff',
        display: 'standalone',
        start_url: '/',
        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' }
        ]
      }
    })
  ]
})

And the Vue version, which is functionally identical apart from the plugin import:

// vue-pwa/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    vue(),
    VitePWA({
      registerType: 'prompt',
      includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
      manifest: {
        name: 'Vue PWA Demo',
        short_name: 'VuePWA',
        description: 'A Vue 3.5 progressive web app built with Vite',
        theme_color: '#42b883',
        background_color: '#ffffff',
        display: 'standalone',
        start_url: '/',
        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' }
        ]
      }
    })
  ]
})

Setting registerType: 'prompt' instead of 'autoUpdate' is a deliberate choice here. Auto-update silently swaps the service worker on the next reload, which can leave a user mid-form-submission running old JavaScript against a new API contract. Prompting them first, which you will wire up in step 9, is the safer default for anything beyond a static content site.

Step 3: write the web app manifest and generate icons

The manifest object in the config above is enough to pass Lighthouse’s basic installability check, but Chrome and Android both expect a 512×512 maskable icon, and Safari on iOS wants an apple-touch-icon in the HTML head, not just the manifest. Generate a full icon set from a single source image rather than hand-exporting six sizes.

# from the project root, with a 1024x1024 source logo saved as logo.png
npx pwa-asset-generator logo.png public --icon-only --favicon --maskable

This drops the generated PNGs into public/, matching the paths referenced in the manifest’s icons array. The MDN Progressive Web Apps documentation covers the full set of manifest fields browsers check when deciding whether to show an install prompt. Reference the same generated file for both the 512×512 standard entry and the maskable entry, as shown in the config above, unless your logo needs extra padding to survive a circular or squircle mask on Android launchers. If the icon touches the edges of the canvas, add roughly 20 percent safe-area padding before regenerating, or Android will crop your logo when it applies the mask.

Step 4: choose a service worker strategy

vite-plugin-pwa offers two service worker strategies, and picking the wrong one is a common source of confusion later. generateSW is the default: Workbox generates the entire service worker for you from a caching config object, and you never write service worker code by hand. injectManifest hands you a real service worker file that you write yourself, with Workbox injecting only the precache manifest array into a placeholder you define.

For most React and Vue single-page apps, generateSW is the right call, and it is what this tutorial uses. Reach for injectManifest only if you need custom logic inside the service worker itself, such as handling a push event with a payload your backend controls, or routing background sync jobs that Workbox’s declarative config cannot express. Both strategies produce a service worker that installs and caches correctly, the difference is purely how much of it you write by hand.

Step 5: add offline caching rules

Static build assets (JS, CSS, the app shell HTML) get precached automatically by generateSW. API responses and third-party assets need explicit runtimeCaching rules, or your app will show a network error the moment it goes offline mid-fetch.

Runtime caching in the React app

// react-pwa/vite.config.ts (inside VitePWA options)
workbox: {
  globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
  runtimeCaching: [
    {
      urlPattern: /^https://api.example.com/.*/i,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api-cache',
        expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 },
        cacheableResponse: { statuses: [0, 200] }
      }
    },
    {
      urlPattern: /.(?:png|jpg|jpeg|svg|webp)$/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'image-cache',
        expiration: { maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 * 30 }
      }
    }
  ]
}

Runtime caching in the Vue app

The Vue configuration block is identical in structure since it lives inside the same workbox option of the same plugin. The only thing that typically changes is the API origin and which asset types your app actually fetches at runtime:

// vue-pwa/vite.config.ts (inside VitePWA options)
workbox: {
  globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
  runtimeCaching: [
    {
      urlPattern: /^https://api.example.com/.*/i,
      handler: 'StaleWhileRevalidate',
      options: {
        cacheName: 'api-cache',
        expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 },
        cacheableResponse: { statuses: [0, 200] }
      }
    }
  ]
}

Notice the handler differs between the two examples: NetworkFirst tries the network and only falls back to cache if the request fails, which suits data that changes often. StaleWhileRevalidate serves the cached copy instantly while refetching in the background, which suits data where a one-request-old response is acceptable. Neither choice is React-specific or Vue-specific, pick per endpoint based on how stale a response can be before it matters to the user.

Step 6: handle the install prompt in both frameworks

Chrome and Edge fire a beforeinstallprompt event when a page meets installability criteria, but they suppress the browser’s default install banner if you call preventDefault() on it, so you can trigger the prompt from your own button instead. The vite-plugin-pwa documentation covers this pattern alongside the framework-specific virtual modules used throughout this tutorial. Safari does not fire this event at all, so any install UI needs a graceful fallback (typically a short “Add to Home Screen” instruction for iOS users).

React install button

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

export default function InstallButton() {
  const [deferredPrompt, setDeferredPrompt] = useState<any>(null)

  useEffect(() => {
    const handler = (e: Event) => {
      e.preventDefault()
      setDeferredPrompt(e)
    }
    window.addEventListener('beforeinstallprompt', handler)
    return () => window.removeEventListener('beforeinstallprompt', handler)
  }, [])

  if (!deferredPrompt) return null

  const install = async () => {
    deferredPrompt.prompt()
    await deferredPrompt.userChoice
    setDeferredPrompt(null)
  }

  return <button onClick={install}>Install app</button>
}

Vue install button

<!-- vue-pwa/src/InstallButton.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

const deferredPrompt = ref<any>(null)

const handler = (e: Event) => {
  e.preventDefault()
  deferredPrompt.value = e
}

onMounted(() => window.addEventListener('beforeinstallprompt', handler))
onUnmounted(() => window.removeEventListener('beforeinstallprompt', handler))

const install = async () => {
  if (!deferredPrompt.value) return
  deferredPrompt.value.prompt()
  await deferredPrompt.value.userChoice
  deferredPrompt.value = null
}
</script>

<template>
  <button v-if="deferredPrompt" @click="install">Install app</button>
</template>

Both components do the same three things: listen for the event, stash it, and replay it on click. The Vue version reads slightly more verbose here only because of the explicit lifecycle imports, in practice both files run under 25 lines.

Step 7: build an update-available notification

Since you set registerType: 'prompt' in step 2, a new service worker will install silently in the background but wait for the user’s permission before it takes control. Without a notification, users have no way to know an update is waiting, and they will keep running stale code until they close every tab.

In the Vue app, the plugin’s virtual module gives you reactive refs directly, which is the shortest path to a working update banner:

// vue-pwa/src/registerSW.ts
import { useRegisterSW } from 'virtual:pwa-register/vue'

const { needRefresh, updateServiceWorker } = useRegisterSW({
  onRegisteredSW(swUrl) {
    console.log('Service worker registered:', swUrl)
  }
})

export { needRefresh, updateServiceWorker }

In the React app, the equivalent is a small custom hook around the same underlying registerSW() function, since React does not have a first-party reactive primitive the plugin can hook into automatically:

// react-pwa/src/useRegisterSW.ts
import { useState, useEffect } from 'react'
import { registerSW } from 'virtual:pwa-register'

export function useUpdateSW() {
  const [needRefresh, setNeedRefresh] = useState(false)
  const [updateSW, setUpdateSW] = useState<((r?: boolean) => Promise<void>) | null>(null)

  useEffect(() => {
    const update = registerSW({
      onNeedRefresh() { setNeedRefresh(true) },
      onOfflineReady() { console.log('App ready to work offline') }
    })
    setUpdateSW(() => update)
  }, [])

  return { needRefresh, updateSW }
}

Render a small banner in each app that calls updateServiceWorker(true) (Vue) or updateSW(true) (React) on click. Both trigger skipWaiting() internally and reload the page with the new service worker in control.

Step 8: test offline mode and installability locally

Service workers do not run under npm run dev in most Vite setups, since the plugin’s dev-mode service worker support is intentionally limited. Build and preview instead:

npm run build
npm run preview
# open the printed localhost URL, then in Chrome DevTools:
# Application tab -> Service Workers -> confirm status "activated and is running"
# Network tab -> throttle to "Offline" -> reload the page

If the page still loads with the network throttled to offline, precaching is working. Open the Application panel’s Manifest section and confirm Chrome shows no installability errors. Test both apps this way before moving on, a manifest typo in one project will not affect the other since they are fully separate Vite builds.

Step 9: run a Lighthouse PWA audit and fix failures

Run Lighthouse against the preview build, not the dev server, since dev-mode disables the service worker and every PWA check will fail automatically.

npx lighthouse http://localhost:4173 --only-categories=pwa --view

Example output from a passing run looks like this:

PWA
 [pass] Installable: web app manifest meets installability requirements
 [pass] Registers a service worker that controls page and start_url
 [pass] Configured for a custom splash screen
 [pass] Sets a theme color for the address bar
 [pass] Content is sized correctly for the viewport
 [pass] Has a viewport meta tag with width or initial-scale

If “Installable” fails, check the manifest’s start_url matches a URL that actually returns 200, and confirm at least one icon is 512×512 or larger. If the service worker check fails, confirm you ran npm run build after your last config change, since Lighthouse audits the built output, not your source files.

Testing installability on real Android and iOS devices

DevTools throttling catches most caching bugs, but it will not catch every installability quirk, especially on iOS, so plug in a real phone before you call the setup finished. On Android, connect the device over USB, enable USB debugging in Developer Options, then open chrome://inspect on your desktop Chrome. Your Vite preview server, forwarded through adb reverse tcp:4173 tcp:4173, will show up as an inspectable tab, and you get the full DevTools Application panel against the real device instead of a desktop simulation.

On iOS, open Safari on the iPhone, load the deployed HTTPS URL (not localhost, since iOS Safari’s remote debugging setup does not tunnel a dev server the same way Android does), and connect the device to a Mac running Safari with the Develop menu enabled. Safari’s Web Inspector shows a Storage panel rather than an Application panel, but it exposes the same cache and service worker state. Confirm the “Add to Home Screen” flow actually launches the app in standalone display mode rather than opening a new Safari tab, since a missing or malformed manifest will silently fall back to browser-tab mode without throwing any visible error.

Test both the React and Vue builds on both platforms before you consider the tutorial’s setup production-ready. It is common for a manifest that passes every Lighthouse check to still render an incorrect splash screen background color on iOS, since Apple’s implementation reads background_color slightly differently than Chrome’s installability heuristics expect.

Step 10: deploy over HTTPS and verify service worker scope

Per web.dev’s PWA learning path, service workers only register on HTTPS origins, with an explicit exception for localhost during development. Deploy each app to its own HTTPS host (Vercel, Netlify, and Cloudflare Pages all provision this automatically) and confirm the deployed manifest and service worker resolve at the expected paths.

curl -I https://your-app.example.com/manifest.webmanifest
curl -I https://your-app.example.com/sw.js

Both should return 200. If you deploy the app into a subdirectory rather than the domain root, set the scope and start_url fields in the manifest to match that subdirectory, or the service worker will register with a scope narrower than your app and silently fail to intercept requests outside its own folder.

Step 11: verify the complete working project structure

After the steps above, each project should look like this before you commit:

react-pwa/
|-- public/
|   |-- pwa-192x192.png
|   |-- pwa-512x512.png
|   |-- apple-touch-icon.png
|-- src/
|   |-- InstallButton.tsx
|   |-- useRegisterSW.ts
|   |-- App.tsx
|   |-- main.tsx
|-- vite.config.ts
|-- package.json

vue-pwa/
|-- public/
|   |-- pwa-192x192.png
|   |-- pwa-512x512.png
|   |-- apple-touch-icon.png
|-- src/
|   |-- InstallButton.vue
|   |-- registerSW.ts
|   |-- App.vue
|   |-- main.ts
|-- vite.config.ts
|-- package.json

Both structures stay close to the default Vite scaffold, PWA support adds four or five small files rather than restructuring the app. That is by design: vite-plugin-pwa is meant to bolt onto an existing app, not force a rewrite.

Step 12: add a CI check so PWA support never silently breaks

A manifest field typo or a missing icon path will not throw a build error, it just quietly breaks installability, and nobody notices until a user reports the install button is gone. Add a Lighthouse CI step that fails the pipeline if the PWA score drops.

# .github/workflows/pwa-check.yml (snippet)
- name: Build and preview
  run: |
    npm run build
    npm run preview &
    sleep 3
- name: Lighthouse PWA audit
  run: npx lighthouse http://localhost:4173 --only-categories=pwa --output=json --output-path=./lh-pwa.json
- name: Fail if PWA score drops
  run: |
    SCORE=$(node -e "console.log(require('./lh-pwa.json').categories.pwa.score)")
    node -e "process.exit(Number('$SCORE') < 1 ? 1 : 0)"

Run this in both repos if React and Vue apps live separately, or as two parallel jobs if they share a monorepo. Either way, catching a broken manifest in CI is a lot cheaper than catching it in a support ticket. If you have not set up CI for either framework yet, see our React vs Vue deployment pipeline guide and React vs Vue testing setup with Vitest and Jest for the surrounding workflow this Lighthouse step plugs into.

Common pitfalls when shipping a React or Vue PWA

  • Testing on the dev server instead of the preview build. Service workers behave differently or don’t run at all under npm run dev in most configurations, leading developers to conclude the setup is broken when it actually works fine in production. Always test with npm run build && npm run preview.
  • Forgetting the maskable icon. A manifest with only a standard 512×512 icon passes some installability checks but produces a cropped or oddly padded icon on Android launchers that apply an adaptive icon mask. Always include a purpose: "maskable" entry with safe-area padding.
  • Caching API responses that contain user-specific or sensitive data with a long TTL. A CacheFirst strategy on an authenticated endpoint can serve one user’s cached data to a different user on a shared device. Use NetworkFirst or exclude authenticated routes from runtime caching entirely, and review how your login flow issues tokens in the first place in our React vs Vue auth setup guide.
  • Using registerType: 'autoUpdate' on an app with forms or multi-step flows. Auto-update can swap the JavaScript bundle mid-session, and if your API contract changed between versions, in-flight requests can fail in confusing ways. Prefer 'prompt' and a visible update banner for anything beyond a static content site.
  • Deploying to a subpath without updating scope and start_url. A service worker registered at / when the app actually lives at /app/ will register with the wrong scope and either fail outright or intercept requests it should not.
  • Assuming Safari behaves like Chrome. Safari on iOS does not fire beforeinstallprompt and has historically applied stricter limits to service worker cache storage. Any install UI needs a manual fallback message for iOS users, and any offline strategy needs to be tested specifically in Safari, not just assumed to work because it works in Chrome.

Troubleshooting guide: 8 issues and fixes

  • Service worker never registers. Check the browser console for a mixed-content or HTTPS error first. If running locally, confirm you are on localhost and not a raw IP address like 192.168.x.x, which most browsers do not treat as a secure context.
  • Install button never appears. Open DevTools Application tab, Manifest section, and check for red installability errors. The most common cause is a missing or too-small icon, or a start_url that returns a non-200 status.
  • Old version keeps loading after deploy. The previous service worker is still active and controlling the page. With registerType: 'prompt', this is expected until the user accepts the update banner. Verify the update flow triggers skipWaiting() correctly.
  • Offline mode shows a blank white screen. Your app shell HTML likely was not included in globPatterns, or the route the user was on when they went offline was never precached. Add a fallback route in navigateFallback so unmatched navigations still serve the cached shell.
  • Lighthouse PWA score stuck below 100 despite a working install prompt. Check for a missing theme-color meta tag, a viewport meta tag without width=device-width, or icons that are present in the manifest but 404 when fetched directly.
  • Vue’s needRefresh ref never flips to true. Confirm you imported from virtual:pwa-register/vue and not the framework-agnostic virtual:pwa-register, which does not expose Vue refs and will silently fail to trigger reactivity.
  • React’s custom hook fires onNeedRefresh twice in development. This is React 19’s Strict Mode double-invoking effects intentionally to surface side-effect bugs. It does not happen in production builds, and can be worked around with a ref-based guard if it complicates local testing.
  • Cached API data goes stale and users report seeing old content. Reduce maxAgeSeconds on the relevant runtimeCaching entry, or switch that endpoint’s handler from CacheFirst to StaleWhileRevalidate so every visit at least kicks off a background refresh.

Advanced tips: push notifications, background sync, and periodic sync

Once the base setup from the steps above is stable, three Workbox-adjacent APIs extend it further, though each adds real complexity and should be added only when a genuine use case demands it.

Push notifications require switching from generateSW to injectManifest, since you need to write a push event listener by hand inside your own service worker file. You also need a backend that can send Web Push messages through the Push API and VAPID keys, this is not something the client alone can trigger. Budget real backend work here, not just a frontend config change.

Background sync lets a failed request (say, a form submission made while offline) get retried automatically once connectivity returns, using the workbox-background-sync module. This is a strong fit for anything write-heavy, like a note-taking app or an offline-first form, where losing a submission is worse than delaying it.

Periodic background sync lets an installed PWA refresh cached content on a schedule even when the app is not open, gated behind a permission prompt and currently supported unevenly across browsers. Treat it as a progressive enhancement rather than something the app depends on, since it will not fire at all on browsers that never shipped support for it, and design your caching so the app still works correctly without it.

Sharing PWA config across a React and Vue monorepo

Some teams run both a React app and a Vue app side by side, often during a gradual migration or when different product teams picked different frameworks. In that setup, duplicating the manifest icons, theme color, and caching rules across two vite.config.ts files invites drift, one app’s cache expiration gets tuned for a new endpoint and the other quietly falls behind.

The fix is to extract the shared parts of the VitePWA() options object, the icon list, theme color, and runtimeCaching rules for endpoints both apps call, into a plain TypeScript file that both configs import.

// shared/pwa-config.ts
export const sharedManifestIcons = [
  { 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' }
]

export const sharedRuntimeCaching = [
  {
    urlPattern: /^https://api.example.com/.*/i,
    handler: 'NetworkFirst' as const,
    options: {
      cacheName: 'api-cache',
      expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 }
    }
  }
]

Each app’s vite.config.ts then imports these arrays and spreads them into its own VitePWA() call, keeping the framework-specific pieces (the plugin import, the app name, the theme color if the two products are branded differently) local to each config. This mirrors the pattern most teams already use for shared ESLint or TypeScript config in a monorepo, and it turns an API cache-rule change into a one-file edit instead of a two-file edit that is easy to forget.

React vs Vue PWA: ecosystem and DX comparison

Both PWA setups run on the exact same underlying plugin and the exact same Workbox engine, so runtime performance and caching behavior are effectively identical between the two once configured correctly. Where they differ is developer experience and the surrounding ecosystem.

Factor React 19.2 + vite-plugin-pwa Vue 3.5 + vite-plugin-pwa
Update-notification code Requires a small custom hook around registerSW() Reactive refs shipped directly by the official Vue virtual module
Install-prompt code useState plus manual event listener cleanup in useEffect ref() plus manual event listener cleanup in onMounted/onUnmounted
Underlying plugin vite-plugin-pwa (4,260 GitHub stars, last pushed May 2026) vite-plugin-pwa (same package, same repo)
Service worker engine Workbox (12,996 GitHub stars, actively maintained) Workbox (same engine, same repo)
Manifest and caching config Identical VitePWA() options object Identical VitePWA() options object
Component reuse for install/update UI Standard React component, portable to React Native only with significant changes since RN has no service workers Standard Vue SFC, same portability constraint applies to Vue-based mobile wrappers

The honest conclusion: this is not a decision point most teams should make based on PWA support alone. The plugin and the caching engine are shared, so the runtime result is the same either way. If your team is already committed to React or Vue for other reasons, layering PWA support on top costs roughly the same afternoon of work regardless of which framework you picked. For more framework tooling comparisons like this one, browse our coding tools guide, and if your PWA needs to serve users in multiple languages, our React vs Vue i18n setup tutorial covers that layer next.

Frequently asked questions

Does vite-plugin-pwa work with both React and Vue?

Yes. The plugin is framework-agnostic at its core, it works through Vite’s plugin API, not through React or Vue specifically. The only framework-specific pieces are the optional virtual modules, virtual:pwa-register for plain JavaScript or React, and virtual:pwa-register/vue for Vue’s reactive refs.

Do I need a backend to build a PWA?

No, not for the core installability, offline caching, and update-notification features covered in this tutorial. You only need a backend once you add push notifications, since sending a Web Push message requires a server capable of hitting the Push API with VAPID credentials.

Why does my service worker work locally but not after deployment?

The most common cause is scope. If your app deploys to a subdirectory instead of the domain root, the manifest’s scope and start_url fields need to match that subdirectory, or the browser registers the service worker with the wrong scope and it never intercepts the requests you expect.

Can I use generateSW and injectManifest in the same project?

No, you pick one strategy per Vite config. Most teams start with generateSW for its zero-code setup and only switch to injectManifest later if they need custom service worker logic that Workbox’s declarative caching config cannot express, such as a custom push event handler.

Will this PWA setup work on iOS Safari?

Installability and offline caching both work on iOS Safari, but the install flow is manual since Safari does not fire the beforeinstallprompt event. Show iOS users a short instruction (tap Share, then Add to Home Screen) instead of relying on the automated install button used in Chrome and Edge.

How much bigger does a service worker make my app’s bundle?

The service worker itself is a separate file that loads independently of your main JavaScript bundle, so it does not add to your initial page-load payload the way a dependency would. It does add its own download and parse cost the first time it registers, but that happens in the background and does not block rendering.

Is registerType: ‘autoUpdate’ ever the right choice?

It can be, for content-focused sites without forms or multi-step flows, such as a blog or a documentation site, where silently serving the newest static content carries little risk. For anything with authenticated sessions, forms, or an API contract that can change between deployments, 'prompt' with a visible update banner is the safer default covered in this tutorial.

Do I need to update anything when React or Vue ship a new version?

Generally no. Since vite-plugin-pwa integrates at the Vite plugin level rather than hooking into React or Vue internals, it has stayed stable across React 19.x minor releases and the Vue 3.5.x line covered in this tutorial. Re-run your Lighthouse CI check after any major framework upgrade to confirm nothing regressed, but no PWA-specific migration work is typically required.

Related Coverage

Source: Tech Insider