here’s the minimal, no-framework recipe to turn a single index.html into a PWA.
- Add a web app manifest
Create manifest.webmanifest at your site root:
{
"name": "My App",
"short_name": "MyApp",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0f172a",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
- Reference it and register a Service Worker in index.html
In your :
Before (or in a module script), register the SW:
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
- Add a basic Service Worker
Create sw.js at your site root. This precaches your app shell and adds simple runtime caching.
/* sw.js */
const APP_VERSION = 'v1';
const APP_CACHE = app-cache-${APP_VERSION};
const RUNTIME_CACHE = 'runtime';
const PRECACHE_ASSETS = [
'/', // if your server serves index.html at /
'/index.html',
'/styles.css', // optional
'/app.js', // optional
'/manifest.webmanifest',
'/icons/icon-192.png',
'/icons/icon-512.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(APP_CACHE).then(cache => cache.addAll(PRECACHE_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.map(k => (k.startsWith('app-cache-') && k !== APP_CACHE) ? caches.delete(k) : null))
)
);
self.clients.claim();
});
// Cache-first for same-origin static, network-first for HTML, fallback to cache offline
self.addEventListener('fetch', event => {
const req = event.request;
const url = new URL(req.url);
// HTML pages: network-first so updates show up
if (req.mode === 'navigate' || (req.destination === 'document')) {
event.respondWith(
fetch(req).then(res => {
const copy = res.clone();
caches.open(RUNTIME_CACHE).then(c => c.put(req, copy));
return res;
}).catch(() => caches.match(req).then(r => r || caches.match('/index.html')))
);
return;
}
// Same-origin static: cache-first
if (url.origin === location.origin) {
event.respondWith(
caches.match(req).then(cached => cached || fetch(req).then(res => {
const copy = res.clone();
caches.open(RUNTIME_CACHE).then(c => c.put(req, copy));
return res;
}))
);
return;
}
// Third-party (e.g., images, fonts): stale-while-revalidate
event.respondWith(
caches.match(req).then(cached => {
const network = fetch(req).then(res => {
caches.open(RUNTIME_CACHE).then(c => c.put(req, res.clone()));
return res;
}).catch(() => cached);
return cached || network;
})
);
});
- File structure
/ (https, not http)
├── index.html
├── manifest.webmanifest
├── sw.js
└── icons/
├── icon-192.png (maskable if possible)
└── icon-512.png
- Serve over HTTPS
PWAs require secure context. Easiest options:
GitHub Pages, Netlify, Vercel, Cloudflare Pages — all serve HTTPS by default.
- Test it
Open DevTools → Application → Manifest (Chrome) to verify the manifest and icons.
Lighthouse → “Progressive Web App” to audit installability & offline.
Reload; you should see “Install”/“Add to Home Screen” available.
- Updates & versioning
Bump APP_VERSION in sw.js when you deploy changes to precached files.
The activate handler above cleans old caches automatically.
Consider adding a small “New version available – Refresh” toast by listening to registration.waiting on the page and calling postMessage({type:'SKIP_WAITING'}) from the SW to swap in-place.
- Nice-to-haves (optional)
maskable icons: export icons with safe padding for Android install banners.
Offline fallback page: add /offline.html and return it when fetch fails.
Content-Security-Policy: lock down sources once things work.
Workbox: if the app grows, replace the manual SW with Workbox build plugins for declarative precaching and strategies.
here’s the minimal, no-framework recipe to turn a single index.html into a PWA.
Create manifest.webmanifest at your site root:
{
"name": "My App",
"short_name": "MyApp",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0f172a",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
In your :
Before (or in a module script), register the SW:
<script> if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js'); }); } </script>Create sw.js at your site root. This precaches your app shell and adds simple runtime caching.
/* sw.js */
const APP_VERSION = 'v1';
const APP_CACHE =
app-cache-${APP_VERSION};const RUNTIME_CACHE = 'runtime';
const PRECACHE_ASSETS = [
'/', // if your server serves index.html at /
'/index.html',
'/styles.css', // optional
'/app.js', // optional
'/manifest.webmanifest',
'/icons/icon-192.png',
'/icons/icon-512.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(APP_CACHE).then(cache => cache.addAll(PRECACHE_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.map(k => (k.startsWith('app-cache-') && k !== APP_CACHE) ? caches.delete(k) : null))
)
);
self.clients.claim();
});
// Cache-first for same-origin static, network-first for HTML, fallback to cache offline
self.addEventListener('fetch', event => {
const req = event.request;
const url = new URL(req.url);
// HTML pages: network-first so updates show up
if (req.mode === 'navigate' || (req.destination === 'document')) {
event.respondWith(
fetch(req).then(res => {
const copy = res.clone();
caches.open(RUNTIME_CACHE).then(c => c.put(req, copy));
return res;
}).catch(() => caches.match(req).then(r => r || caches.match('/index.html')))
);
return;
}
// Same-origin static: cache-first
if (url.origin === location.origin) {
event.respondWith(
caches.match(req).then(cached => cached || fetch(req).then(res => {
const copy = res.clone();
caches.open(RUNTIME_CACHE).then(c => c.put(req, copy));
return res;
}))
);
return;
}
// Third-party (e.g., images, fonts): stale-while-revalidate
event.respondWith(
caches.match(req).then(cached => {
const network = fetch(req).then(res => {
caches.open(RUNTIME_CACHE).then(c => c.put(req, res.clone()));
return res;
}).catch(() => cached);
return cached || network;
})
);
});
/ (https, not http)
├── index.html
├── manifest.webmanifest
├── sw.js
└── icons/
├── icon-192.png (maskable if possible)
└── icon-512.png
PWAs require secure context. Easiest options:
GitHub Pages, Netlify, Vercel, Cloudflare Pages — all serve HTTPS by default.
Open DevTools → Application → Manifest (Chrome) to verify the manifest and icons.
Lighthouse → “Progressive Web App” to audit installability & offline.
Reload; you should see “Install”/“Add to Home Screen” available.
Bump APP_VERSION in sw.js when you deploy changes to precached files.
The activate handler above cleans old caches automatically.
Consider adding a small “New version available – Refresh” toast by listening to registration.waiting on the page and calling postMessage({type:'SKIP_WAITING'}) from the SW to swap in-place.
maskable icons: export icons with safe padding for Android install banners.
Offline fallback page: add /offline.html and return it when fetch fails.
Content-Security-Policy: lock down sources once things work.
Workbox: if the app grows, replace the manual SW with Workbox build plugins for declarative precaching and strategies.