pWaiting: true,
runtimeCaching: [], // Defined separately for clarity
});
const nextConfig: NextConfig = {
reactStrictMode: true,
// Additional Next.js config...
};
export default pwaConfig(nextConfig);
**Architecture Rationale:** `skipWaiting: true` forces the new service worker to activate immediately after installation, preventing stale cache states. Disabling in development preserves hot module replacement and prevents cached responses from masking code changes.
### Step 2: Runtime Caching Strategy
Workbox provides deterministic routing patterns. We separate static assets, API responses, and navigation requests into distinct caching policies. This prevents over-caching dynamic data while ensuring critical UI assets load instantly.
```typescript
// lib/workbox-strategies.ts
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { registerRoute } from 'workbox-routing';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
export const configureWorkboxRoutes = () => {
// Static assets: longest TTL, cache-first
registerRoute(
({ request }) => request.destination === 'style' || request.destination === 'font',
new CacheFirst({
cacheName: 'static-assets-v1',
plugins: [
new ExpirationPlugin({ maxAgeSeconds: 365 * 24 * 60 * 60 }),
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
})
);
// API responses: network-first, short TTL
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-responses-v1',
plugins: [
new ExpirationPlugin({ maxAgeSeconds: 300 }),
new CacheableResponsePlugin({ statuses: [200] }),
],
})
);
// Images: stale-while-revalidate for perceived speed
registerRoute(
({ request }) => request.destination === 'image',
new StaleWhileRevalidate({
cacheName: 'image-cache-v1',
plugins: [
new ExpirationPlugin({ maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
})
);
};
Architecture Rationale: CacheFirst eliminates network latency for immutable assets. NetworkFirst ensures API mutations and user-specific data prioritize freshness, falling back to cache only during outages. StaleWhileRevalidate serves cached images immediately while updating the cache in the background, optimizing perceived performance without blocking the main thread.
The manifest declares installability parameters. We embed it via Next.js metadata API and include Apple-specific meta tags for iOS home screen behavior.
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
manifest: '/manifest.json',
themeColor: '#0f172a',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'ResilientApp',
},
other: {
'mobile-web-app-capable': 'yes',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Step 4: Install Prompt Orchestration
Browsers expose the beforeinstallprompt event, but auto-triggering it violates user experience guidelines. We capture the event, defer prompting, and expose a controlled UI trigger.
// hooks/use-install-prompt.ts
import { useState, useEffect } from 'react';
export function useInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e);
};
window.addEventListener('beforeinstallprompt', handler);
window.addEventListener('appinstalled', () => setIsInstalled(true));
return () => {
window.removeEventListener('beforeinstallprompt', handler);
};
}, []);
const triggerInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') setIsInstalled(true);
setDeferredPrompt(null);
};
return { isInstalled, triggerInstall, isPromptAvailable: !!deferredPrompt };
}
Step 5: Push Notification Architecture
Push messaging requires VAPID authentication keys and a backend signing service. The client subscribes to the push service; the backend sends signed payloads.
// lib/push-subscription.ts
export async function subscribeToPush(publicVapidKey: string) {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicVapidKey),
});
return subscription;
}
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
Backend implementation uses the web-push package with VAPID keys generated via npx web-push generate-vapid-keys. The public key is exposed to the client; the private key remains server-side. Payloads are signed and routed through the browser's push service (FCM for Chrome, APNs for Safari).
Pitfall Guide
1. Ignoring iOS Safari Limitations
Explanation: iOS Safari does not support service workers, push notifications, or native install prompts. Teams often build PWA features assuming cross-browser parity, leading to broken UX on Apple devices.
Fix: Implement graceful degradation. Detect window.matchMedia('(display-mode: standalone)') and navigator.serviceWorker availability. Provide manual "Add to Home Screen" instructions for iOS users and fallback to standard web behavior for push/offline features.
2. Over-Caching API Responses
Explanation: Applying CacheFirst or long TTLs to API routes causes stale user data, cart inconsistencies, and authentication desynchronization.
Fix: Restrict caching to GET requests with explicit Cache-Control headers. Use NetworkFirst with short TTLs (5 minutes) for API routes. Implement cache busting via URL versioning or ETag validation.
3. Blocking the Main Thread During Registration
Explanation: Synchronous service worker registration delays paint and interactive metrics, hurting Core Web Vitals.
Fix: Defer registration using requestIdleCallback or dynamic imports. Example: if ('serviceWorker' in navigator) { requestIdleCallback(() => navigator.serviceWorker.register('/sw.js')); }
4. Misusing the beforeinstallprompt Window
Explanation: The beforeinstallprompt event fires only once per session. If you ignore it or fail to store the reference, the native prompt becomes unavailable until the next page load.
Fix: Capture the event immediately, store it in component state or a global store, and expose it to a user-initiated action. Never auto-prompt on page load.
5. Missing Navigation Fallback Routes
Explanation: When a user navigates to an unregistered route offline, the service worker returns a network error instead of a graceful fallback, breaking the app-like experience.
Fix: Configure Workbox to serve a dedicated /offline page for navigation requests. Use workbox-navigation-preload to speed up initial fetch when online, and define a fallback route in runtimeCaching.
6. Hardcoding VAPID Keys in Client Bundles
Explanation: Exposing the private VAPID key allows malicious actors to send unauthorized push notifications on your behalf, triggering browser security blocks.
Fix: Store the private key exclusively in server environment variables. Expose only the public key to the client. Validate all push subscription endpoints with CSRF tokens or JWT authentication.
7. Assuming Automatic Service Worker Updates
Explanation: Browsers check for SW updates only on navigation or refresh. Users may run stale versions indefinitely, missing critical bug fixes or cache policies.
Fix: Implement a version check on app mount. Compare navigator.serviceWorker.controller.scriptURL with the latest build hash. Prompt users to reload when a new SW is waiting, using registration.waiting.postMessage({ type: 'SKIP_WAITING' }).
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Content-heavy marketing site | CacheFirst for all assets, StaleWhileRevalidate for images | Maximizes perceived speed, reduces CDN egress | Low (minimal SW logic) |
| Transactional e-commerce | NetworkFirst for APIs, CacheFirst for product images, strict TTLs | Prevents stale cart data, ensures pricing accuracy | Medium (cache invalidation logic) |
| Real-time dashboard | NetworkFirst with WebSocket fallback, no aggressive caching | Prioritizes data freshness over offline capability | High (backend push infrastructure) |
| iOS-first audience | Graceful degradation, manual install flow, no SW dependency | iOS lacks SW support; avoids broken UX | Low (feature detection overhead) |
Configuration Template
// public/manifest.json
{
"name": "Resilient Application Platform",
"short_name": "ResilientApp",
"description": "Offline-capable web application with native-grade performance",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"orientation": "portrait",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
],
"screenshots": [
{ "src": "/screenshots/desktop.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" },
{ "src": "/screenshots/mobile.png", "sizes": "750x1334", "type": "image/png", "form_factor": "narrow" }
]
}
// next.config.ts (Production Caching Profile)
import withPWA from 'next-pwa';
export default withPWA({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: { cacheName: 'google-fonts-cache', expiration: { maxEntries: 10, maxAgeSeconds: 365 * 24 * 60 * 60 } }
},
{
urlPattern: ({ request }) => request.destination === 'image',
handler: 'StaleWhileRevalidate',
options: { cacheName: 'image-cache', expiration: { maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 } }
},
{
urlPattern: ({ url }) => url.pathname.startsWith('/api/'),
handler: 'NetworkFirst',
options: { cacheName: 'api-cache', expiration: { maxEntries: 15, maxAgeSeconds: 300 } }
}
]
});
Quick Start Guide
- Initialize PWA dependencies: Run
npm install next-pwa workbox-strategies workbox-expiration workbox-cacheable-response workbox-routing
- Generate VAPID keys: Execute
npx web-push generate-vapid-keys and store the output in your server environment variables
- Create manifest & icons: Place
manifest.json in public/, generate 192x192 and 512x512 PNG icons, and add them to the icons array
- Configure Next.js: Apply the
withPWA wrapper to next.config.ts, define runtime caching policies, and integrate metadata in app/layout.tsx
- Validate deployment: Build with
next build, run npx serve out or deploy to your host, open Chrome DevTools > Application > Service Workers, and verify registration, cache population, and offline fallback behavior