Installable web apps (PWA)
Choose between a PWA, Capacitor, Tauri and Electron, make a Vue app installable with a manifest, and control how its service worker caches files and ships updates.
What you'll learn
When you finish, you can do these.- 1Choose between a PWA, Capacitor, Tauri and Electron
- 2Make a Vue app installable and add an Install button
- 3Cache files and data so the app opens offline
- 4Ship updates that wait for a click on Reload
Picture the notes app this track builds, a small Vue app for writing notes. Its users ask for two things: an icon on their phone's home screen, and notes that still open on the train with no signal. Both are web features. A progressive web app (PWA) is a web app with two additions: a manifest, a JSON file that names the app and lists its icons, and a service worker, a script the browser runs beside your page that can answer requests from a cache.
You first compare a PWA with three other ways to ship a Vue app, then set up the PWA. The Code tab holds the notes app's setup: a plugin in vite.config.ts (the build settings) writes both files, InstallButton.vue offers to install, and ReloadPrompt.vue asks before an update reloads the page. You need one idea from Components and props: a component is one .vue file for one piece of the page, placed in another like an HTML tag, as in <InstallButton />.
Pick the shell first
A shell carries your Vue code outside a browser tab. This track covers four:
- PWA: installs from the browser, updates when you deploy (put a new version online), and skips store review. It gets only what the browser offers, and iOS limits it in the background.
- Capacitor: puts your built app inside real Android and iOS projects that ship through the app stores, and reaches device APIs, the phone's own features, through plugins.
- Tauri: wraps the app in a small desktop program for Windows, macOS and Linux, and phones too. It uses the system's web view (its built-in browser engine) and Rust code for what a page cannot do.
- Electron: also desktop, but it bundles its own Chromium (Chrome's engine) and Node.js (JavaScript outside the browser): the same engine for every user, and a much larger download.
Start with a PWA unless you need the app stores, or a phone or desktop feature the browser lacks. Nothing is lost later: Capacitor and Tauri wrap the same build.
Make it installable
Installing gives the app an icon and a window of its own. Chrome offers to install a web app when all of this holds:
- The page is served over HTTPS (
localhost, your own computer, counts as secure). - The manifest has a
nameorshort_name, astart_url(the page the app opens on), adisplayofstandalone,fullscreen,minimal-uiorwindow-controls-overlay, and icons at 192 and 512 pixels. - The person has clicked the page and spent 30 seconds on it.
A service worker is no longer on that list, though offline needs one.
vite-plugin-pwa, a plugin for Vite, the build tool of a Vue project, goes in vite.config.ts. Its VitePWA() call holds the notes app's manifest:
VitePWA({
registerType: 'prompt',
manifest: {
name: 'Notes',
short_name: 'Notes',
theme_color: '#1566c5',
icons: [
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
],
},
})
On npm run build, the plugin writes the manifest and sw.js, the service worker. It fills in start_url and display: 'standalone', links the manifest from index.html, and expects the icons in public/. theme_color colours the title bar, and registerType sets how updates arrive.
In Nuxt, the framework built on Vue, the @vite-pwa/nuxt module takes the same options under pwa in nuxt.config.ts but leaves your HTML alone. Without its <NuxtPwaManifest /> component in app/app.vue (both files are in the Code tab), the page has no manifest link and Chrome never offers Install.
Chrome also fires a beforeinstallprompt event once the app can be installed. To show your own Install button, keep that event and call its prompt() on a click: Chrome's install dialog opens. useInstallPrompt.ts in the Code tab listens at module scope, the top level of a plain .ts file, because the event can fire before any component mounts (appears on the page).
const deferred = shallowRef<BeforeInstallPromptEvent | null>(null)
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault()
deferred.value = event as BeforeInstallPromptEvent
})
event.preventDefault() stops Chrome's own small install bar. deferred, a ref (a box that tells Vue when it changes), keeps the event, so InstallButton.vue shows an Install Notes button once it holds one.
Only Chromium browsers (Chrome, Edge, Samsung Internet) fire this event. On an iPhone, InstallButton.vue shows a hint instead: tap Share, then Add to Home Screen.
The service worker lifecycle
The service worker, sw.js, lets the notes open offline, and its steps decide when an update reaches the page.
- Registerthe page loads sw.js
- Installprecache the shell
- Waitingold tabs still open
- Activateold caches deleted
- Fetchcache or network
Install precaches the app shell, saving the built JavaScript, CSS and HTML files that match globPatterns, a list of file patterns in vite.config.ts. That is what opens offline. Data fetched while the app runs, its notes and avatars (profile pictures), gets a strategy per address:
runtimeCaching: [
{
urlPattern: ({ url }) => url.pathname.startsWith('/avatars/'),
handler: 'CacheFirst',
options: {
cacheName: 'avatars',
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
},
},
{
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
handler: 'NetworkFirst',
options: { cacheName: 'api', networkTimeoutSeconds: 3 },
},
]
Avatars rarely change: CacheFirst answers from the cache, fetches only what is missing, and keeps at most 200 for 30 days. Notes change: NetworkFirst tries the server and falls back to the last copy when the network fails or takes over three seconds.
The waiting step is how updates work. After you deploy, the browser installs the new sw.js and holds it back while open tabs still run the old one, so no tab mixes two versions.
With registerType: 'prompt', the default, the plugin's useRegisterSW() sets its needRefresh flag to true while the new worker waits. Its offlineReady flag turns true once the app can open offline. A toast, a small notice named ReloadPrompt.vue, shows both:
<div v-if="offlineReady || needRefresh" role="status" class="toast">
<p v-if="needRefresh">A new version of Notes is ready.</p>
<p v-else>Notes now works offline.</p>
<button v-if="needRefresh" type="button" @click="updateServiceWorker()">Reload</button>
</div>
Its Reload button calls updateServiceWorker(), which activates the waiting worker and reloads the page.
What goes wrong
The first mistake hides the service worker while you test. You start the dev server with npm run dev, switch Chrome's DevTools (its developer panel) to Offline, reload, and get the browser's own offline error. The Service workers section of the Application panel is empty: the plugin registers no worker in development unless you ask.
VitePWA({
registerType: 'prompt',
manifest: { name: 'Notes' /* … */ },
})
VitePWA({
registerType: 'prompt',
manifest: { name: 'Notes' /* … */ },
devOptions: { enabled: true },
})
devOptions lets you test registration and the toast in development, where the worker precaches only the entry page. Test offline on the real build: npm run build, then npm run preview.
The second mistake loses work. Someone is halfway through a long note, you deploy, and the page reloads under them.
VitePWA({ registerType: 'autoUpdate' })
autoUpdate makes the new worker skip the waiting step and reloads every open tab. Keep the default and the toast:
VitePWA({ registerType: 'prompt' }) // plus ReloadPrompt.vue
The new version waits until the person clicks Reload, and the note stays.
What to remember
- Pick the shell by need: a PWA first, Capacitor for the app stores and phone APIs, Tauri or Electron for the desktop.
- Chrome offers Install over HTTPS once the manifest has a name,
start_url,displayand 192 and 512 pixel icons. Your own Install button keepsbeforeinstallprompt, which Safari never fires. - Installing needs no service worker, but offline does: precache the app shell, and pick
CacheFirstorNetworkFirstper address for runtime data. - A new service worker waits while old tabs stay open.
registerType: 'prompt'with a toast lets the person choose when to reload. - In Nuxt,
<NuxtPwaManifest />adds the manifest link. Test offline on a production build, not on the dev server.
In your project
Do these in a real project. Each one says how to confirm it worked.- Add the manifest and both icons, run
npm run buildandnpm run preview, and open DevTools at Application, then Manifest. Your app's name and both icons appear with no installability errors, and the address bar shows Chrome's install icon. - Go offline: tick Offline in the Network tab and reload. The app still opens, and under Application, Cache storage, the
apicache holds the last notes response. - Change a heading, rebuild and run preview again, then reload the open tab. The toast says a new version is ready and the Service workers section lists a second worker waiting. Click Reload and the new heading appears.