PWA Implementation Guide
- • Complete Next.js PWA setup with next-pwa
- • Advanced service worker patterns
- • Offline-first caching strategies
- • Background sync and push notifications
- • Performance optimization techniques
- • Banking app PWA implementation
PWA Fundamentals
Progressive Web Apps combine the best of web and mobile apps, offering offline functionality, push notifications, and app-like experiences. This guide covers building production-ready PWAs with Next.js, based on real banking application implementations.
Next.js PWA Setup
// Installation
npm install next-pwa workbox-webpack-plugin
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https?.*/,
handler: 'NetworkFirst',
options: {
cacheName: 'offlineCache',
expiration: {
maxEntries: 200,
},
},
},
],
});
module.exports = withPWA({
// Your Next.js config
});
// public/manifest.json
{
"name": "Banking PWA",
"short_name": "BankPWA",
"description": "Secure banking application",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#3b82f6",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"categories": ["finance", "productivity"],
"screenshots": [
{
"src": "/screenshots/desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
}
]
}Advanced Service Worker
// public/sw.js - Custom service worker
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
import { BackgroundSync } from 'workbox-background-sync';
import { Queue } from 'workbox-background-sync';
// Precache all static assets
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// Banking API caching strategy
registerRoute(
({ url }) => url.pathname.startsWith('/api/accounts'),
new NetworkFirst({
cacheName: 'banking-api',
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
return `${request.url}?timestamp=${Math.floor(Date.now() / 60000)}`;
},
},
],
})
);
// Background sync for transactions
const transactionQueue = new Queue('transactions', {
onSync: async ({ queue }) => {
let entry;
while ((entry = await queue.shiftRequest())) {
try {
await fetch(entry.request);
} catch (error) {
await queue.unshiftRequest(entry);
throw error;
}
}
},
});
// Handle offline transaction submissions
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/transactions') && event.request.method === 'POST') {
if (!navigator.onLine) {
event.respondWith(
transactionQueue.pushRequest({ request: event.request }).then(() => {
return new Response(JSON.stringify({ queued: true }), {
headers: { 'Content-Type': 'application/json' },
});
})
);
}
}
});
// Push notification handling
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
data: data.url,
actions: [
{
action: 'view',
title: 'View Details',
},
{
action: 'dismiss',
title: 'Dismiss',
},
],
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'view') {
event.waitUntil(
clients.openWindow(event.notification.data || '/')
);
}
});Offline Data Management
// lib/offline-storage.ts
class OfflineStorage {
private dbName = 'BankingPWADB';
private version = 1;
private db: IDBDatabase | null = null;
async init() {
return new Promise<void>((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Accounts store
if (!db.objectStoreNames.contains('accounts')) {
const accountStore = db.createObjectStore('accounts', { keyPath: 'id' });
accountStore.createIndex('userId', 'userId', { unique: false });
}
// Transactions store
if (!db.objectStoreNames.contains('transactions')) {
const transactionStore = db.createObjectStore('transactions', { keyPath: 'id' });
transactionStore.createIndex('accountId', 'accountId', { unique: false });
transactionStore.createIndex('timestamp', 'timestamp', { unique: false });
}
// Pending transactions (for sync)
if (!db.objectStoreNames.contains('pendingTransactions')) {
db.createObjectStore('pendingTransactions', { keyPath: 'tempId' });
}
};
});
}
async storeAccounts(accounts: Account[]) {
if (!this.db) await this.init();
const transaction = this.db!.transaction(['accounts'], 'readwrite');
const store = transaction.objectStore('accounts');
for (const account of accounts) {
await store.put({ ...account, lastUpdated: Date.now() });
}
}
async getAccounts(userId: string): Promise<Account[]> {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['accounts'], 'readonly');
const store = transaction.objectStore('accounts');
const index = store.index('userId');
const request = index.getAll(userId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async storePendingTransaction(transaction: Partial<Transaction>) {
if (!this.db) await this.init();
const tx = this.db!.transaction(['pendingTransactions'], 'readwrite');
const store = tx.objectStore('pendingTransactions');
await store.put({
...transaction,
tempId: `temp_${Date.now()}_${Math.random()}`,
createdAt: Date.now(),
});
}
async getPendingTransactions(): Promise<any[]> {
if (!this.db) await this.init();
return new Promise((resolve, reject) => {
const transaction = this.db!.transaction(['pendingTransactions'], 'readonly');
const store = transaction.objectStore('pendingTransactions');
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async clearPendingTransaction(tempId: string) {
if (!this.db) await this.init();
const transaction = this.db!.transaction(['pendingTransactions'], 'readwrite');
const store = transaction.objectStore('pendingTransactions');
await store.delete(tempId);
}
}
export const offlineStorage = new OfflineStorage();
// hooks/useOfflineSync.ts
import { useState, useEffect } from 'react';
export const useOfflineSync = () => {
const [isOnline, setIsOnline] = useState(true);
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
const updateOnlineStatus = () => setIsOnline(navigator.onLine);
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
return () => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
};
}, []);
useEffect(() => {
const updatePendingCount = async () => {
const pending = await offlineStorage.getPendingTransactions();
setPendingCount(pending.length);
};
updatePendingCount();
// Check every 30 seconds
const interval = setInterval(updatePendingCount, 30000);
return () => clearInterval(interval);
}, []);
const syncPendingTransactions = async () => {
const pending = await offlineStorage.getPendingTransactions();
for (const transaction of pending) {
try {
await fetch('/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(transaction),
});
await offlineStorage.clearPendingTransaction(transaction.tempId);
} catch (error) {
console.error('Failed to sync transaction:', error);
}
}
setPendingCount(0);
};
return {
isOnline,
pendingCount,
syncPendingTransactions,
};
};PWA Components
// components/PWAInstallPrompt.tsx
import { useState, useEffect } from 'react';
export const PWAInstallPrompt = () => {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [showInstall, setShowInstall] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e);
setShowInstall(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setShowInstall(false);
}
setDeferredPrompt(null);
};
if (!showInstall) return null;
return (
<div className="fixed bottom-4 left-4 right-4 bg-accent text-white p-4 rounded-lg shadow-lg z-50">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Install Banking App</h3>
<p className="text-sm opacity-90">Add to home screen for quick access</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowInstall(false)}
className="px-3 py-1 text-sm border border-white/30 rounded"
>
Later
</button>
<button
onClick={handleInstall}
className="px-3 py-1 text-sm bg-white text-accent rounded"
>
Install
</button>
</div>
</div>
</div>
);
};
// components/OfflineIndicator.tsx
import { useOfflineSync } from '../hooks/useOfflineSync';
export const OfflineIndicator = () => {
const { isOnline, pendingCount, syncPendingTransactions } = useOfflineSync();
if (isOnline && pendingCount === 0) return null;
return (
<div className="fixed top-20 left-4 right-4 z-40">
{!isOnline && (
<div className="bg-yellow-600 text-white p-3 rounded-lg mb-2">
<div className="flex items-center gap-2">
<Wifi className="w-4 h-4" />
<span>You're offline. Changes will sync when online.</span>
</div>
</div>
)}
{pendingCount > 0 && (
<div className="bg-blue-600 text-white p-3 rounded-lg">
<div className="flex items-center justify-between">
<span>{pendingCount} transactions pending sync</span>
<button
onClick={syncPendingTransactions}
className="px-3 py-1 bg-white text-blue-600 rounded text-sm"
disabled={!isOnline}
>
Sync Now
</button>
</div>
</div>
)}
</div>
);
};
// components/TransactionForm.tsx
import { useState } from 'react';
import { useOfflineSync } from '../hooks/useOfflineSync';
import { offlineStorage } from '../lib/offline-storage';
export const TransactionForm = () => {
const [amount, setAmount] = useState('');
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { isOnline } = useOfflineSync();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
const transaction = {
amount: parseFloat(amount),
description,
type: 'DEBIT' as const,
timestamp: Date.now(),
};
try {
if (isOnline) {
await fetch('/api/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(transaction),
});
} else {
await offlineStorage.storePendingTransaction(transaction);
}
setAmount('');
setDescription('');
} catch (error) {
console.error('Transaction failed:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Amount"
className="w-full p-2 border rounded"
required
/>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description"
className="w-full p-2 border rounded"
required
/>
<button
type="submit"
disabled={isSubmitting}
className="w-full p-2 bg-accent text-white rounded disabled:opacity-50"
>
{isSubmitting ? 'Processing...' : isOnline ? 'Send Transaction' : 'Queue Transaction'}
</button>
{!isOnline && (
<p className="text-sm text-yellow-600">
Transaction will be sent when you're back online
</p>
)}
</form>
);
};Performance Optimization
// lib/performance.ts
export class PWAPerformance {
static preloadCriticalResources() {
const criticalResources = [
'/api/accounts',
'/api/user/profile',
'/icons/icon-192x192.png',
];
criticalResources.forEach(resource => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = resource;
document.head.appendChild(link);
});
}
static async cacheFirstLoad() {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.ready;
// Pre-cache critical data
const cache = await caches.open('pwa-critical');
await cache.addAll([
'/',
'/dashboard',
'/transactions',
'/offline',
]);
}
}
static measurePWAMetrics() {
// Time to Interactive
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
console.log('FCP:', entry.startTime);
}
}
});
observer.observe({ entryTypes: ['paint'] });
// PWA install metrics
window.addEventListener('beforeinstallprompt', () => {
console.log('PWA install prompt shown');
});
window.addEventListener('appinstalled', () => {
console.log('PWA installed');
});
}
}
// app/layout.tsx - PWA integration
import { PWAInstallPrompt } from '../components/PWAInstallPrompt';
import { OfflineIndicator } from '../components/OfflineIndicator';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW registered:', registration);
})
.catch((error) => {
console.log('SW registration failed:', error);
});
}
PWAPerformance.preloadCriticalResources();
PWAPerformance.cacheFirstLoad();
PWAPerformance.measurePWAMetrics();
}, []);
return (
<html lang="en">
<body>
<PWAInstallPrompt />
<OfflineIndicator />
{children}
</body>
</html>
);
}Testing PWA Features
// __tests__/pwa.test.ts
describe('PWA Functionality', () => {
beforeEach(() => {
// Mock service worker
Object.defineProperty(navigator, 'serviceWorker', {
value: {
register: jest.fn(),
ready: Promise.resolve({
active: { postMessage: jest.fn() },
}),
},
});
});
test('should register service worker', async () => {
const { registerSW } = await import('../lib/pwa');
await registerSW();
expect(navigator.serviceWorker.register).toHaveBeenCalledWith('/sw.js');
});
test('should handle offline storage', async () => {
const { offlineStorage } = await import('../lib/offline-storage');
const testAccount = {
id: '1',
userId: 'user1',
balance: 1000,
type: 'SAVINGS',
};
await offlineStorage.storeAccounts([testAccount]);
const accounts = await offlineStorage.getAccounts('user1');
expect(accounts).toContainEqual(expect.objectContaining(testAccount));
});
test('should queue transactions when offline', async () => {
// Mock offline state
Object.defineProperty(navigator, 'onLine', {
value: false,
});
const { offlineStorage } = await import('../lib/offline-storage');
const transaction = {
amount: 100,
description: 'Test transaction',
accountId: '1',
};
await offlineStorage.storePendingTransaction(transaction);
const pending = await offlineStorage.getPendingTransactions();
expect(pending).toHaveLength(1);
expect(pending[0]).toMatchObject(transaction);
});
});
// Lighthouse PWA testing
// package.json scripts
{
"scripts": {
"lighthouse": "lighthouse http://localhost:3000 --preset=desktop --output=html --output-path=./lighthouse-report.html",
"pwa-audit": "lighthouse http://localhost:3000 --only-categories=pwa --output=json --output-path=./pwa-audit.json"
}
}Production PWA Results
100/100
PWA Score
1.2s
Time to Interactive
95%
Cache Hit Rate
78%
Install Rate
Conclusion
Progressive Web Apps with Next.js provide powerful offline capabilities and native app-like experiences. The implementation strategies covered here have proven effective in production banking applications, delivering reliable offline functionality while maintaining security and performance standards.
Need PWA Development Help?
Building production-ready PWAs requires expertise in service workers, caching strategies, and offline functionality. I help teams implement robust PWA solutions.