Build a Progressive Web App with Claude Code
A practical Claude Code PWA guide covering manifest, icons, service workers, offline fallback, caching, and validation.
A Progressive Web App, usually shortened to PWA, is a web application that can feel closer to an installed app. It can expose an app name and icon, launch in a standalone window, cache selected resources, and show a useful offline screen instead of a blank browser error.
The risky part is that PWA work touches several layers at once. A valid manifest.webmanifest is not enough. You also need correctly sized icons, a Service Worker, an offline fallback page, a cache strategy, installability checks, and browser validation. If one icon path returns 404 or one cached HTML file gets stale, the app can look fine in development and still fail in production.
This guide shows a beginner-friendly, copy-pasteable path for implementing a PWA with Claude Code. If you are new to the tool, read the Claude Code getting started guide first, then use the prompts here inside your own repository.
For current official references, keep these open while you implement: web.dev Learn PWA, MDN installable PWA guidance, MDN PWA best practices, Chrome’s update on PWA install criteria, and the Claude Code overview.
The PWA Shape
A useful PWA is a small system, not a single file. The browser reads the manifest for identity and launch behavior. The page registers a Service Worker. The Service Worker sits between the app and the network for requests it controls. When the network fails, it returns a planned fallback instead of leaving the user stranded.
User opens the site
-> index.html links to manifest.webmanifest
-> register-sw.js registers /sw.js
-> sw.js precaches the app shell
-> fetch events choose a cache strategy
-> offline navigations receive offline.html
Before you ask Claude Code to edit anything, decide three things:
| Decision | Example | Why it matters |
|---|---|---|
| Launch URL and scope | / or /app/ | A mismatch can leave pages outside Service Worker control |
| Cached resources | HTML, CSS, JS, images, offline page | Caching everything creates stale or private data risk |
| Offline behavior | Offline page, last visited page, API error | Users need a clear state, not a browser error |
For a content site, course site, or small SaaS dashboard, a conservative first version works well: use Network First for HTML navigations, Cache First for images and icons, and Stale While Revalidate for CSS, JavaScript, and fonts. If you need a deeper strategy discussion, pair this guide with the Claude Code caching strategies article.
Prompt Claude Code in Work Units
Claude Code performs better when the task names the files, strategies, and verification steps. “Make this a PWA” is too open-ended because every framework places assets differently and every deployment path changes the correct scope.
Use a prompt like this:
Turn this existing Vite/React app into a Progressive Web App.
Requirements:
- Add public/manifest.webmanifest
- Reference 192x192, 512x512, and maskable 512x512 PNG icons
- Add public/offline.html
- Add public/sw.js as the Service Worker
- Register it from src/register-sw.js
- Use Network First for HTML navigations
- Use Cache First for images
- Use Stale While Revalidate for CSS, JS, and fonts
- Do not cache POST requests or cross-origin requests
- Add an update notice for a newly installed Service Worker
- Finish with a manual checklist for Chrome DevTools and Lighthouse
Constraints:
- Do not add an empty fetch handler just to satisfy installability
- Explain every changed file
- Mention any paths that depend on the deployment base URL
This prompt gives Claude Code a review target, not just an implementation target. In Masa’s first small course landing page experiment, the code generation was fast, but the real bug was a start_url and scope mismatch. Making Claude Code state those assumptions up front prevented repeated patching.
Create the Manifest and Icons
Create public/manifest.webmanifest. The name appears in install prompts and app lists. short_name is used where space is tight. start_url is the first URL opened from the installed app. scope limits which URLs belong to the app.
{
"id": "/",
"name": "ClaudeCodeLab PWA Demo",
"short_name": "CCLab",
"description": "An offline-ready PWA demo built with Claude Code",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0f766e",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Read offline",
"short_name": "Offline",
"url": "/?shortcut=offline",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192"
}
]
}
]
}
Then link it from your HTML head. In Vite this usually goes in index.html; in Astro, Next.js, or Remix it belongs in the shared document or layout.
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f766e" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
Use real PNG files, not placeholder paths. At minimum, provide 192x192 and 512x512 icons. Add a maskable 512x512 icon with safe padding around the logo, because some launchers crop icons into different shapes. After Claude Code adds the files, open each icon URL directly in the browser and check for a 200 response.
Add the Service Worker
Add public/sw.js. This Service Worker precaches the app shell, deletes old caches on activation, ignores POST and cross-origin requests, and applies different strategies by request type.
const VERSION = "2026-06-02";
const STATIC_CACHE = `static-${VERSION}`;
const RUNTIME_CACHE = `runtime-${VERSION}`;
const APP_SHELL = [
"/",
"/offline.html",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/icon-maskable-512.png"
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(STATIC_CACHE)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
const allowedCaches = [STATIC_CACHE, RUNTIME_CACHE];
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => !allowedCaches.includes(key))
.map((key) => caches.delete(key))
)
)
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(networkFirstPage(request));
return;
}
if (request.destination === "image") {
event.respondWith(cacheFirst(request));
return;
}
if (["style", "script", "font"].includes(request.destination)) {
event.respondWith(staleWhileRevalidate(request));
}
});
async function networkFirstPage(request) {
const cache = await caches.open(RUNTIME_CACHE);
try {
const response = await fetch(request);
if (response.ok) {
await cache.put(request, response.clone());
}
return response;
} catch {
const cached = await cache.match(request);
return (
cached ||
(await caches.match("/offline.html")) ||
new Response("Offline", {
status: 503,
headers: { "Content-Type": "text/plain; charset=utf-8" }
})
);
}
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME_CACHE);
await cache.put(request, response.clone());
}
return response;
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(RUNTIME_CACHE);
const cached = await cache.match(request);
const networkPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => undefined);
if (cached) return cached;
return (
(await networkPromise) ||
new Response("Network error", {
status: 504,
headers: { "Content-Type": "text/plain; charset=utf-8" }
})
);
}
This is deliberately modest. It does not cache API responses, form submissions, payments, private dashboards, or cross-origin assets. If your app handles user-specific data, design HTTP caching, authorization, and Service Worker caching together. Treat the browser cache as storage, not as magic performance dust.
Add an Offline Page
Add public/offline.html. It should have no dependency on remote fonts, analytics, or ad scripts. The user is already offline; the fallback must be boring and reliable.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>You are offline</title>
<style>
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #f8fafc;
color: #0f172a;
display: grid;
min-height: 100vh;
place-items: center;
}
main {
max-width: 36rem;
padding: 2rem;
}
a {
color: #0f766e;
font-weight: 700;
}
</style>
</head>
<body>
<main>
<h1>You are offline</h1>
<p>Reconnect and refresh the page. Recently opened pages may still be available.</p>
<p><a href="/">Back to home</a></p>
</main>
</body>
</html>
One common trap is cache.addAll(APP_SHELL). If any asset in that array returns 404, the install step rejects and the Service Worker never reaches the expected state. After adding this file, verify /offline.html, /manifest.webmanifest, and every icon URL in the browser.
Register and Update the Worker
Create src/register-sw.js and import it from your app entry point. Registration should be delayed until load so it does not compete with the first render.
export async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) {
console.info("Service Worker is not supported in this browser.");
return;
}
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/"
});
registration.addEventListener("updatefound", () => {
const worker = registration.installing;
if (!worker) return;
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && navigator.serviceWorker.controller) {
showUpdateNotice();
}
});
});
} catch (error) {
console.error("Service Worker registration failed:", error);
}
});
}
function showUpdateNotice() {
const button = document.querySelector("[data-refresh-app]");
if (!button) return;
button.hidden = false;
button.addEventListener(
"click",
() => {
window.location.reload();
},
{ once: true }
);
}
Import it once:
import { registerServiceWorker } from "./register-sw.js";
registerServiceWorker();
The update notice matters because Service Workers have a lifecycle. A newly downloaded worker may wait while an old tab is still controlled by the previous worker. Without a visible refresh path, users can end up with stale HTML and fresh JavaScript, or the opposite.
Add a Respectful Install Button
Some Chromium-based browsers expose beforeinstallprompt when the app meets installability conditions. Do not assume every browser supports this event. Use it as a progressive enhancement: show a button when the event is available, and leave normal browser install menus alone when it is not.
let deferredPrompt = null;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredPrompt = event;
const installButton = document.querySelector("[data-install-app]");
if (installButton) {
installButton.hidden = false;
}
});
document.querySelector("[data-install-app]")?.addEventListener("click", async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
console.info("Install prompt result:", choice.outcome);
deferredPrompt = null;
});
window.addEventListener("appinstalled", () => {
console.info("PWA was installed.");
});
Install buttons are also business features. A course site can say “Install this course reader.” A B2B dashboard can say “Open this workspace like an app.” A conference site can say “Keep the schedule available on-site.” Keep it contextual and useful; avoid blocking modals that interrupt reading.
Validate with DevTools and Lighthouse
Do not rely on the old idea of a single “PWA score.” Chrome has changed how installability and Lighthouse PWA audits are handled. In current practice, use Chrome DevTools Application panels for PWA-specific checks, then run Lighthouse for performance, accessibility, best practices, and SEO.
Build and serve the production output. localhost is allowed for Service Worker testing.
npm run build
npx serve dist -l 4173
npx lighthouse http://localhost:4173 --view --only-categories=performance,accessibility,best-practices,seo
Use this manual checklist:
| Check | Where | Pass condition |
|---|---|---|
| Manifest | DevTools > Application > Manifest | App name, start URL, theme color, and icons load without errors |
| Service Worker | Application > Service Workers | /sw.js is activated and controls the page |
| Offline navigation | DevTools Network set to Offline | A previous page or offline.html appears |
| Cache Storage | Application > Cache Storage | Static and runtime caches contain expected entries |
| Lighthouse | Lighthouse report | No regression in performance, accessibility, SEO, or best practices |
Ask Claude Code to report the observed result, not just “done.” A good final answer from the tool should list changed files, DevTools observations, offline reload behavior, and any deployment assumptions.
Use Cases and Monetization
PWA work pays off when people return often, use the site in unreliable networks, or benefit from a home-screen launch point.
| Use case | PWA value | Monetization path |
|---|---|---|
| Technical blog or course library | Readers can keep learning during commutes | Paid templates, course upsells, consulting |
| Internal dashboard | Daily users open it faster | Implementation support and team training |
| Event or venue guide | Schedules and maps survive poor networks | Sponsor slots, premium material, lead capture |
| Commerce or booking flow | Product images and recent browsing feel faster | Account signup, cart recovery, repeat visits |
For ClaudeCodeLab, the strongest CTA is not “we made it installable.” It is “we made repeated use measurable.” Track read completion, install button clicks, offline fallback hits, returning users, and product CTA clicks. If you want implementation templates and Claude Code prompt packs, browse the product library. For teams, package the PWA work with cache review, deployment validation, and analytics events.
Pitfalls to Avoid
The first pitfall is path mismatch. If your app is hosted under /docs/ or /app/, absolute paths like /sw.js and /icons/icon-512.png may point to the wrong place. Tell Claude Code the production base URL before it edits.
The second pitfall is caching HTML with Cache First. It feels fast until a deploy changes hashed assets and old HTML keeps pointing at files that no longer exist. Use Network First for navigations unless you have a careful revision strategy.
The third pitfall is caching personalized API responses. User profiles, payments, permissions, cart contents, and inventory should not be thrown into a generic runtime cache. Handle those with authenticated APIs and explicit HTTP caching rules.
The fourth pitfall is treating installability as the whole goal. An app can be installable and still slow, inaccessible, confusing, or stale. Validate the complete user path: first load, second load, offline reload, update after deploy, and uninstall/reinstall.
The fifth pitfall is forgetting that Service Workers persist. When debugging, unregister the worker, clear Cache Storage, reload, and repeat the first-visit flow. Many “it works on my machine” reports are really old-worker reports.
Tested Result
In a small Vite course landing page test, Masa found that the manifest, registration code, and fallback page were quick to add with Claude Code. The slower work was verification: icon URLs, offline reloads, and clearing stale caches after each revision. The safest path is to ship the smallest useful offline fallback first, then gradually cache pages and assets that clearly improve the repeat-user experience.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.