Progressive Web Apps with Next.js
Introduction to Progressive Web Apps (PWAs)
Progressive Web Apps combine the best of web and native applications. They provide app-like experiences with offline functionality, push notifications, and installability while maintaining the reach and accessibility of the web.
PWA Benefits: PWAs offer improved performance, offline capability, reduced data usage, automatic updates, and native app-like experiences without app store restrictions.
PWA Core Concepts
- Service Workers: Scripts that run in the background, enabling offline functionality and caching
- Web App Manifest: JSON file that controls how the app appears when installed
- HTTPS: Required for service workers and many PWA features
- Responsive Design: Works across all devices and screen sizes
Setting Up PWA in Next.js
Install next-pwa
npm install next-pwa
# or
yarn add next-pwa
# or
pnpm add next-pwa
Configure next-pwa
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60 // 1 year
}
}
},
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60 // 1 day
}
}
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'images',
expiration: {
maxEntries: 60,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30 days
}
}
}
]
});
module.exports = withPWA({
// Your Next.js config
reactStrictMode: true
});
Web App Manifest
Creating manifest.json
// public/manifest.json
{
"name": "My Next.js PWA",
"short_name": "MyPWA",
"description": "A powerful PWA built with Next.js",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}
Linking Manifest in Layout
// app/layout.tsx
export const metadata = {
manifest: '/manifest.json',
themeColor: '#000000',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'My Next.js PWA'
}
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
</head>
<body>{children}</body>
</html>
);
}
Tip: Use tools like RealFaviconGenerator or PWABuilder to generate all required icons and manifest settings.
Service Worker Strategies
Cache Strategies
// 1. CacheFirst: Fastest, best for static assets
// Returns cached content if available, falls back to network
// 2. NetworkFirst: Fresh content priority
// Tries network first, falls back to cache if offline
// 3. CacheOnly: Offline-only
// Only uses cache, never network
// 4. NetworkOnly: Online-only
// Only uses network, never cache
// 5. StaleWhileRevalidate: Best of both
// Returns cache immediately, updates cache in background
Custom Service Worker
// public/sw.js
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
'/',
'/offline',
'/styles/main.css',
'/scripts/main.js'
];
// Install event - cache essential resources
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
.then(() => self.skipWaiting())
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
// Fetch event - serve from cache or network
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Cache hit - return cached response
if (response) {
return response;
}
return fetch(event.request).then((response) => {
// Check if valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone response for cache
const responseToCache = response.clone();
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
}).catch(() => {
// Network failed, return offline page
return caches.match('/offline');
})
);
});
Offline Support
Offline Page
// app/offline/page.tsx
export default function OfflinePage() {
return (
<div className="offline-container">
<h1>You're Offline</h1>
<p>Please check your internet connection and try again.</p>
<button onClick={() => window.location.reload()}>
Retry
</button>
</div>
);
}
Online/Offline Detection
// hooks/useOnlineStatus.ts
'use client';
import { useState, useEffect } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
// Set initial status
setIsOnline(navigator.onLine);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
// components/OnlineIndicator.tsx
'use client';
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
export function OnlineIndicator() {
const isOnline = useOnlineStatus();
if (isOnline) return null;
return (
<div className="offline-banner">
<span>⚠️ You're currently offline</span>
</div>
);
}
Push Notifications
Requesting Permission
// lib/notifications.ts
'use client';
export async function requestNotificationPermission() {
if (!('Notification' in window)) {
console.log('This browser does not support notifications');
return false;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
}
export async function subscribeUser() {
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
)
});
// Send subscription to your server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
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);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
Sending Push Notifications
// app/api/push/send/route.ts
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:your-email@example.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
export async function POST(request: Request) {
const { subscription, title, body } = await request.json();
const payload = JSON.stringify({
title,
body,
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png'
});
try {
await webpush.sendNotification(subscription, payload);
return Response.json({ success: true });
} catch (error) {
console.error('Error sending push notification:', error);
return Response.json(
{ error: 'Failed to send notification' },
{ status: 500 }
);
}
}
Service Worker Push Handler
// public/sw.js (add to existing service worker)
self.addEventListener('push', (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: data.icon || '/icons/icon-192x192.png',
badge: data.badge || '/icons/badge-72x72.png',
vibrate: [200, 100, 200],
tag: 'notification-tag',
requireInteraction: true,
actions: [
{ action: 'open', title: 'Open App' },
{ action: 'close', title: 'Close' }
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'open') {
event.waitUntil(
clients.openWindow('/')
);
}
});
Warning: Push notifications require HTTPS in production. They won't work on HTTP (except localhost for testing).
App Install Prompt
Custom Install Button
// components/InstallPrompt.tsx
'use client';
import { useState, useEffect } from 'react';
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e);
setShowPrompt(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') {
console.log('User accepted install');
}
setDeferredPrompt(null);
setShowPrompt(false);
};
if (!showPrompt) return null;
return (
<div className="install-prompt">
<p>Install our app for a better experience!</p>
<button onClick={handleInstall}>Install</button>
<button onClick={() => setShowPrompt(false)}>Not Now</button>
</div>
);
}
Background Sync
Implementing Background Sync
// lib/background-sync.ts
'use client';
export async function registerBackgroundSync(tag: string) {
if (!('serviceWorker' in navigator) || !('sync' in registration)) {
console.log('Background Sync not supported');
return false;
}
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
return true;
}
// Usage: Queue API request when offline
export async function saveDataOffline(data: any) {
// Save to IndexedDB
const db = await openDB();
await db.add('pending-requests', data);
// Register sync
await registerBackgroundSync('sync-data');
}
// public/sw.js (add to service worker)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-data') {
event.waitUntil(syncPendingRequests());
}
});
async function syncPendingRequests() {
const db = await openIndexedDB();
const requests = await db.getAll('pending-requests');
for (const request of requests) {
try {
await fetch(request.url, {
method: request.method,
body: JSON.stringify(request.data),
headers: { 'Content-Type': 'application/json' }
});
// Remove from IndexedDB on success
await db.delete('pending-requests', request.id);
} catch (error) {
console.error('Sync failed:', error);
}
}
}
Performance Optimization
Pre-caching Critical Assets
// next.config.js - with next-pwa
const withPWA = require('next-pwa')({
dest: 'public',
buildExcludes: [/middleware-manifest\.json$/],
publicExcludes: ['!robots.txt', '!sitemap.xml'],
additionalManifestEntries: [
{ url: '/offline', revision: 'offline-v1' }
]
});
Measuring PWA Performance
// lib/performance.ts
'use client';
export function measurePWAPerformance() {
if (!('performance' in window)) return;
// Measure Service Worker registration time
performance.mark('sw-registration-start');
navigator.serviceWorker.register('/sw.js').then(() => {
performance.mark('sw-registration-end');
performance.measure(
'sw-registration',
'sw-registration-start',
'sw-registration-end'
);
const measure = performance.getEntriesByName('sw-registration')[0];
console.log(`SW registered in ${measure.duration}ms`);
});
// Report to analytics
if ('sendBeacon' in navigator) {
window.addEventListener('load', () => {
const perfData = JSON.stringify({
type: 'pwa-performance',
metrics: performance.getEntriesByType('navigation')
});
navigator.sendBeacon('/api/analytics', perfData);
});
}
}
Exercise: Build a Full-Featured PWA
Create a PWA with Next.js that includes:
- Service worker with CacheFirst strategy for images
- NetworkFirst strategy for API calls
- Custom offline page with retry functionality
- Push notification system with subscription management
- Install prompt with custom UI
- Background sync for form submissions when offline
- Online/offline status indicator
- Performance monitoring
Bonus: Add app shortcuts, share target API, and file handling API for a native-like experience.
Testing PWA Features
Using Lighthouse
# Run Lighthouse audit
npx lighthouse https://yourapp.com --view
# Check PWA score
npx lighthouse https://yourapp.com --preset=pwa --view
PWA Checklist
- ✅ Served over HTTPS
- ✅ Responsive design across devices
- ✅ Valid manifest.json with all required fields
- ✅ Service worker registered and active
- ✅ Offline functionality working
- ✅ Icons for all sizes (72px to 512px)
- ✅ Fast load time (<3s on 3G)
- ✅ Installable (shows install prompt)
- ✅ Accessible (ARIA labels, keyboard navigation)
- ✅ Works without JavaScript (progressive enhancement)
Deployment Considerations
Vercel Deployment
# vercel.json
{
"headers": [
{
"source": "/sw.js",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=0, must-revalidate"
},
{
"key": "Service-Worker-Allowed",
"value": "/"
}
]
}
]
}
Pro Tip: Test your PWA on multiple devices and browsers. iOS Safari has different PWA support compared to Android Chrome. Always provide fallbacks for unsupported features.