To turn a website into a progressive web app, serve it over HTTPS, add a manifest.webmanifest with a name, start_url, display mode and 192 and 512 pixel icons, link it from every page, and register a service worker that caches files for offline use. Chromium browsers then offer install; Safari users add it from Share.
| Part | What it does |
|---|---|
| manifest.webmanifest | Name, icons, colours and how the app opens |
| Service worker (sw.js) | Caches files so pages load offline |
| HTTPS | Required for service workers and install |
Updated
Fill in the form to get a manifest and the head tags to paste, then copy the service worker and install script. The worker on this page was run in a real browser with the network cut before we published it.
Example output for the default fields.
{
"name": "Field Notes",
"short_name": "Notes",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4f98fb",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#4f98fb">
<link rel="icon" href="/icons/icon-192.png" sizes="192x192" type="image/png">
<link rel="apple-touch-icon" href="/icons/icon-192.png">Installing gives your site its own icon and a window without the address bar. A service worker adds offline pages. Push needs both, and Apple only allows it for sites the user has added to the Home Screen.
| Feature | Chrome / Edge (desktop, Android) | Safari on iPhone and iPad | Firefox |
|---|---|---|---|
| Install to home screen or desktop | Yes, with an install prompt | Yes, by hand: Share, then Add to Home Screen | Android: yes. Desktop: limited, check your version |
| Custom install button (beforeinstallprompt) | Yes | No | No |
| Offline with a service worker | Yes | Yes | Yes |
| Web push notifications | Yes | Only for a site added to the Home Screen, iOS 16.4 or later | Yes |
| Storage that lasts | Yes, can be evicted under pressure | In Safari tabs, script-written storage can be cleared after 7 days of browser use with no visit; Home Screen apps keep their own count | Yes, can be evicted under pressure |
| Background sync | Yes | No | No |
Three files make a site work offline: the worker, a fallback page and a few lines to register it. We ran these exact files on localhost in Chromium: the worker installed, the cache filled, and with the server stopped the home page loaded from cache while a page never visited showed offline.html.
Pages try the network first so people see fresh content, then fall back to a cached copy, then to offline.html. Files like CSS, scripts and icons come from the cache first. Changing VERSION makes the next activate step delete the old caches.
const VERSION = "v1";
const STATIC_CACHE = `static-${VERSION}`;
const PAGE_CACHE = `pages-${VERSION}`;
const PRECACHE = ["/", "/offline.html", "/app.css", "/app.js", "/icons/icon-192.png"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
const keep = [STATIC_CACHE, PAGE_CACHE];
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => !keep.includes(k)).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET" || new URL(request.url).origin !== self.location.origin) return;
// Pages: network first, then the cached copy, then the offline page.
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((res) => {
const copy = res.clone();
caches.open(PAGE_CACHE).then((c) => c.put(request, copy));
return res;
})
.catch(async () => (await caches.match(request)) || caches.match("/offline.html"))
);
return;
}
// Assets: cache first, then network (and store what comes back).
event.respondWith(
caches.match(request).then(
(hit) =>
hit ||
fetch(request).then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(STATIC_CACHE).then((c) => c.put(request, copy));
}
return res;
})
)
);
});Keep it self-contained: inline styles and no external files, because nothing else may be reachable when it shows.
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Offline</title></head>
<body>
<h1>You are offline</h1>
<p>This page will work again when the connection returns.</p>
</body>
</html>The beforeinstallprompt event only fires in Chromium browsers (Chrome, Edge, Samsung Internet, Opera). Safari and Firefox never send it, so the button stays hidden there and people use the browser's own menu instead.
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch((err) => console.error("SW failed", err));
});
}
let deferredPrompt = null;
const btn = document.getElementById("install"); // <button id="install" hidden>Install app</button>
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault(); // stop the browser's own mini prompt
deferredPrompt = e; // keep it for our button
btn.hidden = false;
});
btn.addEventListener("click", async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice; // "accepted" or "dismissed"
console.log("Install choice:", outcome);
deferredPrompt = null;
btn.hidden = true;
});
window.addEventListener("appinstalled", () => { btn.hidden = true; });Chrome no longer insists on a service worker before it offers install; the manifest and the items below are what it checks. Lighthouse 12 removed its PWA category, so check installability in Chrome DevTools instead: Application panel, then Manifest, which lists any problems under Installability.
| Requirement | Detail |
|---|---|
| HTTPS | Served securely. localhost counts as secure for testing. |
| Manifest name | name or short_name |
| Manifest icons | At least a 192 px and a 512 px icon |
| start_url | Present in the manifest |
| display | fullscreen, standalone, minimal-ui or window-controls-overlay |
| prefer_related_applications | Missing or false |
| Engagement | The visitor has clicked or tapped the page at least once and spent some time on it |
Install with npm i -D vite-plugin-pwa. The plugin writes the manifest and generates a Workbox service worker at build time, so you do not hand-write sw.js. Put the icons in public/.
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.ico", "apple-touch-icon.png"],
manifest: {
name: "Field Notes",
short_name: "Notes",
start_url: "/",
display: "standalone",
theme_color: "#4f98fb",
background_color: "#ffffff",
icons: [
{ src: "icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "icon-512-maskable.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
},
workbox: { navigateFallback: "/index.html" },
}),
],
});Next.js serves this file at /manifest.webmanifest and adds the link tag for you. For offline support, put the sw.js above in public/ and register it from a small client component.
// app/manifest.ts
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Field Notes",
short_name: "Notes",
start_url: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#4f98fb",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
],
};
}Render this once in app/layout.tsx. Skip registration in development so a cached build does not hide your edits.
"use client";
import { useEffect } from "react";
export function RegisterSW() {
useEffect(() => {
if (process.env.NODE_ENV !== "production") return;
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(console.error);
}
}, []);
return null;
}
Cache-first answers from the cache and only goes to the network for things it has not stored. It is fast and right for files whose content never changes under the same name, such as hashed scripts, fonts and icons.
Network-first asks the server and only uses the cache when that fails. It suits HTML pages, where showing an old copy after an update would confuse people. The worker above uses both: network-first for page loads, cache-first for everything else.
Registering the worker from a subfolder path like /js/sw.js, which limits it to /js/ pages.
Forgetting to change VERSION, so returning visitors keep old CSS and scripts for weeks.
Caching responses from other sites or error responses. The worker above skips cross-origin requests and only stores successful asset responses.
Testing only in Chrome. Open the site on an iPhone to see what Safari users actually get.
In current Chrome, no: a valid manifest, HTTPS and some user engagement are enough for the install option. You still want a worker, because without one the installed app shows the browser's error page when offline.
Yes, with limits. Safari users install from the Share menu, there is no install prompt event, and push notifications only work for a site added to the Home Screen on iOS 16.4 or later. Offline caching works.
beforeinstallprompt only fires in Chromium browsers, only when the install criteria are met, and not when the app is already installed. Check the Manifest section of the DevTools Application panel for the reason.
Lighthouse 12 dropped the PWA category. Use the Chrome DevTools Application panel: the Manifest view lists installability errors and icon problems, and the Service workers view shows registration, updates and an Offline switch.
Change the cache VERSION in sw.js. The browser sees the changed worker, installs it, and the activate step removes the old caches. With skipWaiting and clients.claim, the new worker takes over on the next load.
Related: Favicon generator, JSON formatter, HTML viewer, React Router examples, React icons guide, All free tools.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.