PWA: how to turn a website into an installable mobile app
A Progressive Web App installs on the phone like a native app, works offline, and can send push notifications. How to add it to an existing React or HTML site in under an hour.
Published: June 3, 2025
If you already have a working website, you can turn it into an installable phone app without touching the app stores, without Swift, without Kotlin. On Android, the browser automatically prompts “Add to home screen” when it finds the minimum requirements. On iOS you still have to do it manually from the Safari menu, but it works. The site becomes an icon, opens without an address bar, and can work offline.
The 3 ingredients of a PWA
A PWA is a normal website with three additions:
1. manifest.json — describes the app: name, icon, colors, how it opens:
{
"name": "My App",
"short_name": "App",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0066cc",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
Link it in the HTML <head>: <link rel="manifest" href="/manifest.json">.
2. Service Worker — a JavaScript script that runs in the background, intercepts network requests, and manages the cache. This is the piece that enables offline mode:
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open('v1').then(c => c.addAll(['/', '/index.html', '/app.js', '/style.css']))
);
});
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then(cached => cached || fetch(e.request))
);
});
This “cache-first” strategy serves assets from the local cache when available. For API calls, use “network-first” instead: try the network, fall back to cache.
3. HTTPS — mandatory. Localhost is the only exception for development.
In React with Vite
If you use Vite, the vite-plugin-pwa plugin automates everything — it generates the service worker, manifest, and cache files:
npm install -D vite-plugin-pwa
In vite.config.ts:
import { VitePWA } from 'vite-plugin-pwa'
export default {
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'My App',
short_name: 'App',
theme_color: '#0066cc',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }
]
}
})
]
}
This automatically generates a Workbox-powered service worker, handles precaching of build assets, and silently updates the app in the background when a new version ships.
Offline mode and push notifications
The offline page is what you show when the user has no network and requests a page not in cache. In the service worker, intercept failed requests and return the offline page:
self.addEventListener('fetch', (e) => {
e.respondWith(
fetch(e.request).catch(() => caches.match('/offline.html'))
);
});
Push notifications are technically possible (Push API + a server calling subscriptions) but require a dedicated backend, per-user subscription management, and VAPID keys. For most projects they are not needed: the main value of a PWA is installability and offline speed. Add notifications only if you have a specific use case — order delivered, new message, system alert.
What to do
- Add
manifest.jsonand the link in the<head>today: it is the fastest change (15 minutes) and already enables “Add to home screen” on Android - Use
vite-plugin-pwaif you are on React/Vite: it generates the Workbox service worker automatically and saves you hours of cache debugging - Test with Chrome DevTools → Application tab → Manifest and Service Workers: Chrome tells you exactly what is missing to satisfy the installability criteria