A progressive web app (PWA) is an ordinary website that the browser can install like an app: it gets its own icon and window, and it still opens when the device is offline. For a Vue app built with Vite, getting there takes one plugin, a few manifest fields and two icons. The same plugin can then tell users when a new version is ready, and Chrome's DevTools show whether the app qualifies to install. Nuxt apps get all of it through a module.
What a PWA needs
Three things turn a site into an installable app.
- A web app manifest: a JSON file with the app's name, icons, start URL, display mode and theme colour. The browser reads it to draw the icon and to decide whether the site qualifies.
- A service worker: a script the browser runs in the background, separate from your page. It intercepts network requests and can answer them from a cache, which is what makes the app open with no connection.
- HTTPS: service workers only register on secure origins.
localhostcounts as secure, so development and local previews work without a certificate.
Chrome's installability criteria spell out the manifest part: a name or short_name, icons at 192 and 512 pixels, a start_url, and a display value of standalone, fullscreen, minimal-ui or window-controls-overlay. For the automatic install prompt Chrome also waits until the user has interacted with the page at least once and spent 30 seconds on it.
Since Chrome 108 on Android and 112 on desktop, a service worker is no longer required to install from the browser menu. You still want one, because without it the installed app is an error page the moment the network drops.
Add vite-plugin-pwa to a Vite app
vite-plugin-pwa generates the manifest and the service worker from your Vite config, using Google's Workbox library (version 7.3) for the service worker code.
Install it as a development dependency:
npm install -D vite-plugin-pwa
Add the plugin next to the Vue plugin:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
vue(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
manifest: {
name: 'Shopping List',
short_name: 'Shopping',
description: 'A shopping list that works offline',
theme_color: '#ffffff',
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' },
],
},
}),
],
})
What each option does:
registerType: 'autoUpdate': a new service worker takes over as soon as it is installed, and open tabs reload. The default,'prompt', asks the user first; the next section switches to it.manifest: becomesmanifest.webmanifestin your build, and the plugin injects its<link>tag intoindex.html.icons: two PNG files you put inpublic/, named exactly as listed. The third entry reuses the 512 pixel image withpurpose: 'maskable', which lets Android crop it into its adaptive icon shapes, so keep the artwork inside the middle 80 percent.includeAssets: files inpublic/that the service worker should cache on top of the build output. By default it caches every JavaScript, CSS and HTML file the build emits, and it adds the manifest icons automatically.
Then add three tags to index.html: a theme-color meta tag that matches the manifest's theme_color, a description, and an apple-touch-icon link, which iOS uses in preference to the manifest icons:
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="A shopping list that works offline" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
Run npm run build. Next to your usual bundles, dist/ now contains manifest.webmanifest, sw.js, a workbox-*.js helper and a registerSW.js script that index.html loads. Open sw.js to see the precache list: the files the worker stores when it installs. Files in assets/ already carry a hash in their names, and every other file gets a revision hash, so after a deploy the browser downloads only the files that changed.
Show an update prompt with useRegisterSW
autoUpdate is the right choice for a read-only app. For anything with a form it is risky: the reload arrives whenever the browser finishes installing the new worker, and a half-typed order is gone. Switch registerType to 'prompt' and show a small toast instead. The plugin ships a Vue composable for exactly this in a virtual module, a module that exists only inside Vite's build and has no file on disk.
TypeScript needs to know the module exists. Add a reference to the env.d.ts file that create-vue generated:
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/vue" />
Then build the prompt:
<script setup lang="ts">
import { useRegisterSW } from 'virtual:pwa-register/vue'
const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW()
function close() {
offlineReady.value = false
needRefresh.value = false
}
</script>
<template>
<div v-if="offlineReady || needRefresh" role="status" class="pwa-toast">
<p v-if="offlineReady">Ready to work offline.</p>
<p v-else>A new version is available.</p>
<button v-if="needRefresh" type="button" @click="updateServiceWorker()">Reload</button>
<button type="button" @click="close">Close</button>
</div>
</template>
useRegisterSW() registers the service worker and returns two refs and a function:
offlineReadyturns true once the first worker has installed and filled its cache.needRefreshturns true when a newer worker is installed and waiting.updateServiceWorker()tells the waiting worker to take over, and the page reloads once it has. Older examples passtrue; the plugin has ignored that argument since 0.13.2.
Put <ReloadPrompt /> once in App.vue. Because the component now registers the worker, the build no longer emits registerSW.js. If you stay with autoUpdate, needRefresh never flips, and the component is still useful for the offline message alone.
One caveat for local testing: Workbox decides whether an update is "external" with a one-minute heuristic, so if you rebuild twice within a minute you may see the offline message where you expected the update prompt. Wait a minute between builds and it behaves.
Serve offline pages and cache API data
The generated worker precaches your build and uses index.html as the navigation fallback. That is the whole trick for a single-page app: any route the user opens offline is answered with the cached shell, and Vue Router renders the right page from the URL. Nothing to configure. Routes that are served by something else, say an /admin app behind the same domain, are excluded with navigateFallbackDenylist.
Your API responses are not cached by default, and most of the time that is correct. Where it makes sense, add a runtime caching rule under workbox in the same VitePWA() call (the manifest options are left out below). This one answers product requests from the network and falls back to the last copy for a day:
VitePWA({
registerType: 'prompt',
workbox: {
navigateFallbackDenylist: [/^\/admin/],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.example\.com\/products/,
handler: 'NetworkFirst',
options: {
cacheName: 'products',
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 },
},
},
],
},
devOptions: { enabled: true },
})
devOptions.enabled serves the manifest and the service worker during npm run dev too, so you can inspect them without a build. The development worker precaches only the entry page, and it answers only the root route unless you widen devOptions.navigateFallbackAllowlist. Treat it as a way to look, and test offline behaviour on a build.
Test installation and offline mode in Chrome
- Run
npm run build && npm run previewand open the preview URL. - Open DevTools, then the Application panel, then Manifest. The Identity and Presentation sections show what Chrome read. An Installability section appears only when something is wrong, with the reason, such as an icon that failed to load.
- Look at the right end of the address bar. The install icon appears once the criteria are met. Click it; the app opens in its own window with your
theme_colorin the title bar. - In Application, open Service workers, tick Offline and reload the app. It should render as before. Untick when done.
- Deploy to a host that serves HTTPS and redirects HTTP to it. Check that
manifest.webmanifestis served asapplication/manifest+json. Not every host does this out of the box: the plugin's deployment guide shows the header rule for Netlify, Vercel, Nginx and Apache.
Set up the same PWA in Nuxt
For a Nuxt 3 app, the @vite-pwa/nuxt module wraps the same plugin. Version 1.0.0 was published on the same day, 29 March 2025, depends on vite-plugin-pwa 1.0, and works with Nuxt 3.17.
npx nuxi@latest module add @vite-pwa/nuxt
The plugin options move under a pwa key:
export default defineNuxtConfig({
modules: ['@vite-pwa/nuxt'],
pwa: {
registerType: 'prompt',
manifest: {
name: 'Shopping List',
short_name: 'Shopping',
theme_color: '#ffffff',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
],
},
client: { installPrompt: true },
},
})
Two things differ from the Vite setup:
- The manifest link comes from a component. Put
<NuxtPwaManifest />once inapp.vue.<VitePwaManifest />is the same component under another name, so use one, not both. - A
$pwaobject replaces the composable. It hasneedRefresh,offlineReadyandupdateServiceWorker(). Withclient.installPrompton, the module also holds back Chrome's install prompt so your page decides when to offer it:$pwa.showInstallPromptturns true when Chrome is ready, and$pwa.install()opens the install dialog.
$pwa is undefined while the PWA is disabled, which includes the dev server unless devOptions.enabled is on, so guard it:
<template>
<div>
<NuxtPwaManifest />
<NuxtPage />
<button v-if="$pwa?.needRefresh" type="button" @click="$pwa?.updateServiceWorker()">
Reload for the new version
</button>
<button v-if="$pwa?.showInstallPrompt" type="button" @click="$pwa?.install()">
Install the app
</button>
</div>
</template>
What a PWA cannot do, and when to use Capacitor
A PWA runs inside the browser's rules, and some things stay out of reach. It has no listing in the iOS App Store, and Google Play only accepts it through a separate wrapping step. Access to Bluetooth, NFC, contacts, the file system and background work depends on the browser, and Safari supports less of it than Chrome. On iOS, web push arrived in 16.4 and only for apps added to the Home Screen.
Capacitor 7 takes the same Vue build and ships it inside a native iOS or Android shell, with plugins for those APIs and a real store listing. It is more work: two native projects, two app stores, two review queues.