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 devin most configurations, leading developers to conclude the setup is broken when it actually works fine in production. Always test withnpm 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
CacheFirststrategy on an authenticated endpoint can serve one user’s cached data to a different user on a shared device. UseNetworkFirstor 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
scopeandstart_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
beforeinstallpromptand 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
localhostand not a raw IP address like192.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_urlthat 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 triggersskipWaiting()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 innavigateFallbackso 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
needRefreshref never flips to true. Confirm you imported fromvirtual:pwa-register/vueand not the framework-agnosticvirtual:pwa-register, which does not expose Vue refs and will silently fail to trigger reactivity. - React’s custom hook fires
onNeedRefreshtwice 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
maxAgeSecondson the relevantruntimeCachingentry, or switch that endpoint’s handler fromCacheFirsttoStaleWhileRevalidateso 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.