How to Turn Your Website into a Progressive Web App

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.

The three parts of a PWA
PartWhat it does
manifest.webmanifestName, icons, colours and how the app opens
Service worker (sw.js)Caches files so pages load offline
HTTPSRequired 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.

Manifest generator

Example output for the default fields.

manifest.webmanifest

{
  "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"
    }
  ]
}

Tags for the head of every page

<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">

What you get, and the limits on iPhone

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.

What a PWA can do, by platform
FeatureChrome / Edge (desktop, Android)Safari on iPhone and iPadFirefox
Install to home screen or desktopYes, with an install promptYes, by hand: Share, then Add to Home ScreenAndroid: yes. Desktop: limited, check your version
Custom install button (beforeinstallprompt)YesNoNo
Offline with a service workerYesYesYes
Web push notificationsYesOnly for a site added to the Home Screen, iOS 16.4 or laterYes
Storage that lastsYes, can be evicted under pressureIn Safari tabs, script-written storage can be cleared after 7 days of browser use with no visit; Home Screen apps keep their own countYes, can be evicted under pressure
Background syncYesNoNo

A working service worker

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.

sw.js: a minimal service worker that works offline

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;
        })
    )
  );
});

offline.html: the fallback page

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>

An install button

app.js: register the worker and show an install button

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; });

Install criteria today

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.

Chrome's install requirements
RequirementDetail
HTTPSServed securely. localhost counts as secure for testing.
Manifest namename or short_name
Manifest iconsAt least a 192 px and a 512 px icon
start_urlPresent in the manifest
displayfullscreen, standalone, minimal-ui or window-controls-overlay
prefer_related_applicationsMissing or false
EngagementThe visitor has clicked or tapped the page at least once and spent some time on it

Vite and Next.js setups

Vite: vite-plugin-pwa

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 App Router: app/manifest.ts

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" },
    ],
  };
}

Next.js: register public/sw.js from a client component

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 or network-first

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.

Common mistakes

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.

How it works

  1. Make icons at 192 and 512 pixels, plus a maskable version with the logo inside the middle 80 percent.
  2. Generate the manifest with the form, save it as manifest.webmanifest at the site root and paste the head tags into every page.
  3. Save sw.js, offline.html and the register script, and list your core files in PRECACHE.
  4. Open Chrome DevTools, Application panel: check Manifest for install problems and Service workers for the running worker. Tick Offline and reload to test.
  5. When you change cached files, bump VERSION so returning visitors get the new ones.

Tips

Frequently asked questions

Does a PWA need a service worker to be installable?

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.

Do PWAs work on iPhone?

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.

Why does my install button never appear?

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.

How do I check a PWA now that Lighthouse has no PWA score?

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.

How do I update a PWA after a release?

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.

More in Guides

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.

Product

Who It's For

Features

Resources

Company

View Sitemap