Building PWA React Apps for Maximum Engagement
A practical guide to building production-ready Progressive Web Apps with React and Next.js, Service Workers, Web App Manifests, offline support, and push notifications.

Progressive Web Apps sit at the intersection of web reach and native app capability. When done well, a PWA is installable, works offline, loads instantly on repeat visits, and can send push notifications, all from a web browser, with no App Store approval process. Here's how to build one properly with React and Next.js.
What Makes an App a PWA
Three requirements must all be met for a browser to offer the "Install" prompt:
- HTTPS, required for Service Workers to register
- Web App Manifest, describes your app's identity (name, icons, display mode, colours)
- Service Worker, intercepts network requests for offline support and caching
All three must be present. Missing any one of them and the browser won't offer installation.
Setting Up the Web App Manifest in Next.js
Next.js makes the manifest simple, create app/manifest.ts:
// app/manifest.ts
import type { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "My Application",
short_name: "MyApp",
description: "A fast, reliable Progressive Web App built with Next.js",
start_url: "/",
display: "standalone", // Hides browser UI, feels native
orientation: "portrait",
background_color: "#ffffff", // Splash screen background
theme_color: "#4f46e5", // Browser toolbar colour on Android
icons: [
{
src: "/icons/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable", // Adaptive icon, fills the shape on Android
},
],
screenshots: [
{
src: "/screenshots/desktop.png",
sizes: "1280x720",
type: "image/png",
form_factor: "wide",
label: "Dashboard view",
},
],
categories: ["productivity", "utilities"],
lang: "en",
};
}
The maskable icon purpose is important for Android, it allows the OS to apply its own shape (circle, squircle, etc.) to your icon. Generate maskable icons at maskable.app.
Service Workers: The Engine of Offline Support
A Service Worker is a JavaScript file that runs in the background, separate from the main browser thread. It intercepts network requests, serves cached responses, and enables background sync and push notifications.
Setting Up Workbox in Next.js
Workbox (from Google) provides pre-built caching strategies. Use next-pwa for easy integration:
npm install next-pwa
// next.config.ts
import withPWA from "next-pwa";
const nextConfig = withPWA({
dest: "public",
disable: process.env.NODE_ENV === "development", // Don't interfere with HMR in dev
register: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "google-fonts",
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
},
},
},
{
urlPattern: /\/_next\/static\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "static-assets",
expiration: { maxAgeSeconds: 60 * 60 * 24 * 30 }, // 30 days
},
},
{
urlPattern: /\/api\/.*/i,
handler: "NetworkFirst",
options: {
cacheName: "api-responses",
expiration: { maxAgeSeconds: 60 * 5 }, // Cache for 5 minutes as fallback
networkTimeoutSeconds: 3,
},
},
],
})({});
export default nextConfig;
Caching Strategies Explained
Choosing the right caching strategy per resource type is critical:
| Strategy | When to Use | Examples |
|---|---|---|
| CacheFirst | Rarely changes, freshness less important | Fonts, library scripts, images |
| NetworkFirst | Needs to be fresh, offline fallback acceptable | API responses, user data |
| StaleWhileRevalidate | Show cached, update in background | Blog posts, product listings |
| NetworkOnly | Must be real-time, no caching acceptable | Payment endpoints, secure data |
| CacheOnly | Fully offline, never fetch | Pre-cached app shell |
Custom Service Worker for Advanced Patterns
For push notifications and background sync, extend the default Workbox setup:
// public/sw.js (custom service worker additions)
import { precacheAndRoute } from "workbox-precaching";
// Precache the app shell
precacheAndRoute(self.__WB_MANIFEST);
// Push notifications
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: "/icons/icon-192.png",
badge: "/icons/badge-72.png",
tag: data.tag,
data: { url: data.url },
actions: [
{ action: "open", title: "Open" },
{ action: "dismiss", title: "Dismiss" },
],
})
);
});
// Handle notification click
self.addEventListener("notificationclick", (event) => {
event.notification.close();
if (event.action === "open" || !event.action) {
event.waitUntil(clients.openWindow(event.notification.data.url));
}
});
// Background sync for offline form submissions
self.addEventListener("sync", (event) => {
if (event.tag === "submit-form") {
event.waitUntil(processPendingSubmissions());
}
});
Offline User Experience
An offline-capable app is only as good as its offline UX. Users need to know what works offline and what doesn't.
Offline Fallback Page
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center text-center px-4">
<div className="text-6xl mb-6">📡</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
You are offline
</h1>
<p className="text-gray-500 dark:text-gray-400 max-w-sm mb-8">
Some features are unavailable without an internet connection. Previously
loaded pages are still accessible.
</p>
<button
onClick={() => window.location.reload()}
className="bg-indigo-600 text-white px-6 py-2 rounded-lg font-semibold hover:bg-indigo-700 transition-colors"
>
Try again
</button>
</div>
);
}
Detecting and Responding to Network Status
"use client";
import { useState, useEffect } from "react";
export function NetworkStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const updateStatus = () => setIsOnline(navigator.onLine);
window.addEventListener("online", updateStatus);
window.addEventListener("offline", updateStatus);
setIsOnline(navigator.onLine);
return () => {
window.removeEventListener("online", updateStatus);
window.removeEventListener("offline", updateStatus);
};
}, []);
if (isOnline) return null;
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-gray-900 text-white px-4 py-2 rounded-full text-sm font-medium z-50">
No internet connection
</div>
);
}
Push Notifications
Push notifications are the most impactful PWA feature for re-engagement. Implementation requires a push service (we recommend web-push + VAPID keys for self-hosted, or a service like OneSignal).
Requesting Permission and Subscribing
// lib/push-notifications.ts
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;
export async function subscribeToPushNotifications() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
throw new Error("Push notifications not supported");
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
throw new Error("Push notification permission denied");
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // Required, all pushes must show a notification
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Save subscription to your server
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
});
return subscription;
}
Installation Prompt: Don't Let It Surprise Users
The browser's default install prompt appears at the worst moments. Intercept it and show it when it makes sense:
"use client";
import { useState, useEffect } from "react";
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault(); // Stop the default prompt
setDeferredPrompt(e);
setShowBanner(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
async function handleInstall() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
setDeferredPrompt(null);
setShowBanner(false);
// Track outcome in analytics
}
if (!showBanner) return null;
return (
<div className="fixed bottom-4 left-4 right-4 sm:left-auto sm:right-4 sm:w-80 bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-100 dark:border-gray-800 p-5 z-50">
<h3 className="font-bold text-gray-900 dark:text-white mb-1">Install App</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Install for a faster experience with offline access.
</p>
<div className="flex gap-2">
<button onClick={handleInstall} className="flex-1 bg-indigo-600 text-white rounded-lg py-2 text-sm font-semibold">
Install
</button>
<button onClick={() => setShowBanner(false)} className="px-3 py-2 text-gray-400 hover:text-gray-600 text-sm">
Not now
</button>
</div>
</div>
);
}
For a production web application that needs PWA capabilities, these patterns form the complete implementation foundation.
Frequently Asked Questions
Do PWAs work on iOS?
Yes, with limitations. Safari supports Service Workers, Web App Manifests, and basic offline support. Push notifications on iOS require iOS 16.4+ and only work when the app is installed to the home screen. The install experience on iOS is manual (Share → Add to Home Screen), the native install prompt isn't available.
How do I test my PWA in development?
Use next build && next start to test in production mode (Service Workers don't register in development). In Chrome DevTools, the Application panel has dedicated sections for manifests, service workers, and cache storage. Use Lighthouse's "Progressive Web App" audit for a full checklist.
Can a PWA replace a native mobile app?
For many use cases, yes, especially internal tools, dashboards, and content apps. Camera access, GPS, Bluetooth, and deep OS integration still work better in native apps. For consumer-facing apps that need the App Store for discoverability, a PWA plus a native app shell (Capacitor, React Native) is a common hybrid approach.
How do I handle Service Worker updates?
Use skipWaiting and clientsClaim to activate updates immediately, or prompt users to refresh: listen for the waiting state on the SW registration and show a "New version available, tap to update" banner.
Does using a PWA affect SEO?
Not negatively. PWAs are indexed normally by Googlebot. The Service Worker intercepts requests for human users, not crawlers. Good PWA practices (performance, offline support) often improve Core Web Vitals, which positively affects SEO.
Tags





