@baryodev/pwa-kit
v0.5.0
Published
Drop-in PWA kit: install prompt (Android + iOS), a secure network-first service worker, and helpers. Framework-agnostic React.
Maintainers
Readme
Turn any web app into an installable, offline-capable one. Framework-agnostic (the React component uses inline styles, so no CSS setup needed).
Why
Turning a site into an installable, offline-capable app means three fiddly pieces every time: an install affordance (Android has a prompt event, iOS doesn't), a service worker with a sane caching strategy, and cache hygiene so one user's data isn't served to another. This packages all three.
Install
npm i @baryodev/pwa-kit1. Service worker
Generate the worker source and write it to a file your site serves at the root (so its scope covers the whole app):
// scripts/gen-sw.mjs
import { writeFileSync } from "node:fs";
import { generateServiceWorker } from "@baryodev/pwa-kit";
writeFileSync(
"public/sw.js",
generateServiceWorker({
cachePrefix: "myapp", // -> myapp-shell-v1 / myapp-api-v1
apiPrefix: "/api/", // requests treated as live data
skipPaths: ["/api/auth/", "/api/files/"], // never cached
navigationFallback: "/", // shown when offline with nothing cached
}),
);// package.json
"scripts": { "prebuild": "node scripts/gen-sw.mjs" }Caching strategy
- App shell / static assets: cache-first (fast, works offline).
- Navigations: network-first, cached page as an offline fallback.
- API
GETs: network-first — online always gets fresh data; the last good response is kept only as an offline fallback, so lists still show like a native app with no signal. - Writes (
POST/PUT/…) always hit the network.
Security
- Auth and file paths are never cached.
- Only same-origin
200responses are cached. - Call
clearApiCache()on sign-in and sign-out so cached data from one account is never served to another on a shared device (see below).
2. Register + cache hygiene
import { registerServiceWorker, clearApiCache } from "@baryodev/pwa-kit";
registerServiceWorker(); // defaults to "/sw.js"
// in your auth code:
function onLogin(session) { clearApiCache(); /* store token... */ }
function onLogout() { clearApiCache(); /* drop token... */ }3. Install prompt (React)
import { InstallHint } from "@baryodev/pwa-kit";
export default function App() {
return (
<>
{/* ...your app... */}
<InstallHint appName="MyApp" iconUrl="/icon-192.png" accentColor="#2563eb" />
</>
);
}- Android/Chrome: shows an Install app button wired to
beforeinstallprompt. - iOS Safari: shows Share → Add to Home Screen instructions (iOS has no install event).
- Hidden when already installed; dismissible (remembered in
localStorage).
4. Native feel when installed (React)
Browsers let users pinch- and double-tap-zoom any page — great for the web, but it makes an
installed app feel like a website. Mount StandaloneViewport once near your root: when the app is
launched as an installed PWA it locks the viewport so zoom is disabled; in a normal browser tab it
does nothing, so accessibility zoom still works there.
import { StandaloneViewport } from "@baryodev/pwa-kit";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<StandaloneViewport />
</body>
</html>
);
}Not using React? Call the helper directly (e.g. in a client entry). It's a no-op on the server and in a browser tab, and returns a cleanup function:
import { lockViewportWhenStandalone } from "@baryodev/pwa-kit";
const cleanup = lockViewportWhenStandalone();Your base viewport should still allow zoom (so the browser tab stays accessible) — Next.js:
export const viewport = { width: "device-width", initialScale: 1 };5. Who installed it (adoption tracking)
There is no cross-browser API that answers "is this PWA installed?" from a normal browser tab.
navigator.getInstalledRelatedApps() is Chromium-only and needs related_applications in your
manifest; iOS has no install API at all. What every platform can tell you is whether the current
launch is the installed app, so record that and keep it.
reportPwaStatus fires once on launch, and again if the user installs during the session
(appinstalled). You supply send, so the report goes through your own authenticated API:
import { reportPwaStatus } from "@baryodev/pwa-kit/report";
reportPwaStatus((report) => api.post("/api/pwa/report", report));
// report: { deviceId, displayMode, platform, installed }Want a one-off read instead of a subscription? Use pwaStatus(), which returns the same shape (or
null on the server).
Import from @baryodev/pwa-kit/report, not the package root. The root re-exports the React
components, so it pulls React in. This subpath carries only the reporting helpers: no dependencies,
no React, and it builds to a single self-contained file. That means a non-React app, or a site with
no bundler at all, can use it directly:
<script type="module">
import { reportPwaStatus } from "/js/pwa-kit-report.js"; // copied from dist/report.js
reportPwaStatus((r) => fetch("/api/pwa/report", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(r),
}));
</script>Things to design around:
installedmeans currently running as installed. Someone who installed but is in a browser tab reportsfalseon that launch, which is why the server should store the firsttrueit sees.appinstalledis Chromium-only. On iOS you find out at the next home-screen launch, not at the moment of install.deviceIdis a random id inlocalStorage, so it is per browser, not per account. Private mode or cleared storage yields a new one and the same person can be counted twice.
6. Web manifest (you provide)
pwa-kit doesn't generate your manifest (icons/colors are yours), but it needs one. Minimum:
{
"name": "MyApp",
"short_name": "MyApp",
"start_url": "/",
"display": "standalone",
"theme_color": "#2563eb",
"background_color": "#ffffff",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}Link it and the Apple bits in your <head> (Next.js: use the metadata API):
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="MyApp" />API
| Export | Description |
|--------|-------------|
| generateServiceWorker(options) | Returns the service-worker source as a string. |
| registerServiceWorker(url?) | Registers the worker after load. No-op on the server. |
| clearApiCache() | Tells the worker to drop cached API data. Call on sign-in/out. |
| isStandalone() | true when running as an installed PWA. |
| lockViewportWhenStandalone() | Disables zoom when installed (no-op in a browser tab). Returns a cleanup fn. |
| <InstallHint /> | Dismissible install prompt (Android button / iOS instructions). |
| <StandaloneViewport /> | Mounts lockViewportWhenStandalone for you. Renders nothing. |
