A PWA gives your website app-like capabilities: offline access, home screen installation, and push notifications.
Web App Manifest
// public/manifest.json
{
"name": "My Business App",
"short_name": "MyApp",
"description": "Professional business solutions",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0f172a",
"orientation": "portrait-primary",
"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": "maskable"
}
],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}
Add Manifest to Layout
// app/layout.tsx
export const metadata = {
manifest: "/manifest.json",
themeColor: "#0f172a",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "MyApp",
},
};
Service Worker
// public/sw.js
const CACHE_NAME = "v1";
const STATIC_ASSETS = ["/", "/offline"];
// Install: pre-cache essential assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Activate: clean old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
).then(() => self.clients.claim())
);
});
// Fetch: network-first with cache fallback
self.addEventListener("fetch", (event) => {
const { request } = event;
// Skip non-GET and chrome-extension requests
if (request.method !== "GET") return;
if (request.url.startsWith("chrome-extension://")) return;
// HTML pages: network-first
if (request.headers.get("accept")?.includes("text/html")) {
event.respondWith(
fetch(request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return response;
})
.catch(() =>
caches.match(request).then((cached) => cached || caches.match("/offline"))
)
);
return;
}
// Static assets: cache-first
if (
request.url.match(/\.(js|css|png|jpg|jpeg|webp|svg|woff2?)$/)
) {
event.respondWith(
caches.match(request).then(
(cached) =>
cached ||
fetch(request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return response;
})
)
);
return;
}
// API calls: network-only
event.respondWith(fetch(request));
});
// Background sync
self.addEventListener("sync", (event) => {
if (event.tag === "sync-forms") {
event.waitUntil(syncPendingForms());
}
});
async function syncPendingForms() {
const db = await openDB();
const pending = await db.getAll("pending-submissions");
for (const submission of pending) {
try {
await fetch(submission.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(submission.data),
});
await db.delete("pending-submissions", submission.id);
} catch {
// Will retry on next sync event
}
}
}
Register Service Worker
// components/ServiceWorkerRegistration.tsx
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegistration() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
registration.addEventListener("updatefound", () => {
const newWorker = registration.installing;
newWorker?.addEventListener("statechange", () => {
if (
newWorker.state === "activated" &&
navigator.serviceWorker.controller
) {
// New version available
dispatchEvent(new CustomEvent("sw-update-available"));
}
});
});
})
.catch(console.error);
}
}, []);
return null;
}
Install Prompt
"use client";
import { useEffect, useState } from "react";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
// Check if already installed
if (window.matchMedia("(display-mode: standalone)").matches) {
setIsInstalled(true);
return;
}
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener("beforeinstallprompt", handler);
window.addEventListener("appinstalled", () => setIsInstalled(true));
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") setIsInstalled(true);
setDeferredPrompt(null);
};
if (isInstalled || !deferredPrompt) return null;
return (
<div className="fixed bottom-4 right-4 bg-card border rounded-lg shadow-lg p-4 max-w-sm z-50">
<p className="text-sm font-medium">Install our app</p>
<p className="text-xs text-muted-foreground mt-1">
Get quick access from your home screen with offline support.
</p>
<div className="flex gap-2 mt-3">
<button
onClick={handleInstall}
className="text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded"
>
Install
</button>
<button
onClick={() => setDeferredPrompt(null)}
className="text-xs text-muted-foreground px-3 py-1.5"
>
Not now
</button>
</div>
</div>
);
}
Offline Page
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="text-center max-w-md">
<div className="text-6xl mb-4">
<svg className="w-16 h-16 mx-auto text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.364 5.636a9 9 0 010 12.728M5.636 18.364a9 9 0 010-12.728m12.728 0L5.636 18.364" />
</svg>
</div>
<h1 className="text-2xl font-bold mb-2">You are offline</h1>
<p className="text-muted-foreground mb-6">
Check your internet connection and try again.
</p>
<button
onClick={() => window.location.reload()}
className="bg-primary text-primary-foreground px-4 py-2 rounded"
>
Retry
</button>
</div>
</div>
);
}
Update Banner
"use client";
import { useEffect, useState } from "react";
export function UpdateBanner() {
const [showUpdate, setShowUpdate] = useState(false);
useEffect(() => {
const handler = () => setShowUpdate(true);
window.addEventListener("sw-update-available", handler);
return () => window.removeEventListener("sw-update-available", handler);
}, []);
if (!showUpdate) return null;
return (
<div className="fixed top-0 left-0 right-0 bg-primary text-primary-foreground py-2 px-4 text-center text-sm z-50">
A new version is available.{" "}
<button
onClick={() => window.location.reload()}
className="underline font-medium"
>
Refresh
</button>
</div>
);
}
Need a Fast, Installable Web App?
We build progressive web apps that work offline and feel native. Contact us to learn more.