Building Progressive Web Apps with Next.js
Transform your Next.js application into a Progressive Web App. Learn about service workers, caching strategies, offline functionality, and app-like experiences.
Progressive Web Apps (PWAs) combine the best of web and mobile apps, providing app-like experiences with offline functionality, push notifications, and home screen installation. This guide shows how to build PWAs with Next.js.
Setting Up a PWA with Next.js
First, install the necessary dependencies and configure your Next.js application for PWA functionality.
npm install next-pwa workbox-webpack-plugin
const withPWA = require('next-pwa')({ dest: 'public', register: true, skipWaiting: true, disable: process.env.NODE_ENV === 'development', runtimeCaching: [ { urlPattern: /^https?.*/, handler: 'NetworkFirst', options: { cacheName: 'offlineCache', expiration: { maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days }, }, }, ],});module.exports = withPWA({ // Your Next.js config reactStrictMode: true, swcMinify: true,});
Web App Manifest
{ "name": "My PWA Application", "short_name": "PWA App", "description": "A Progressive Web App built with Next.js", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#000000", "orientation": "portrait", "scope": "/", "lang": "en", "categories": ["productivity", "utilities"], "screenshots": [ { "src": "/screenshot-wide.png", "sizes": "1280x720", "type": "image/png", "form_factor": "wide" }, { "src": "/screenshot-narrow.png", "sizes": "750x1334", "type": "image/png", "form_factor": "narrow" } ], "icons": [ { "src": "/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "any" }, { "src": "/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "any" }, { "src": "/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "any" }, { "src": "/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "any" }, { "src": "/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "any" }, { "src": "/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "any" }, { "src": "/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "/maskable-icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "/maskable-icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ]}
Service Worker Implementation
const CACHE_NAME = 'pwa-cache-v1';const urlsToCache = [ '/', '/static/js/bundle.js', '/static/css/main.css', '/manifest.json'];// Install event - cache resourcesself.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME) .then(cache => { console.log('Opened cache'); return cache.addAll(urlsToCache); }) );});// Fetch event - serve cached content when offlineself.addEventListener('fetch', event => { event.respondWith( caches.match(event.request) .then(response => { // Return cached version or fetch from network if (response) { return response; } return fetch(event.request); } ) );});// Activate event - clean up old cachesself.addEventListener('activate', event => { const cacheWhitelist = [CACHE_NAME]; event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames.map(cacheName => { if (cacheWhitelist.indexOf(cacheName) === -1) { return caches.delete(cacheName); } }) ); }) );});
Offline Functionality
import { useState, useEffect } from 'react';export function useOfflineStatus() { const [isOffline, setIsOffline] = useState(!navigator.onLine); useEffect(() => { const handleOnline = () => setIsOffline(false); const handleOffline = () => setIsOffline(true); window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); return isOffline;}
import { useOfflineStatus } from '../hooks/useOfflineStatus';export function OfflineIndicator() { const isOffline = useOfflineStatus(); if (!isOffline) return null; return ( <div className="fixed top-0 left-0 right-0 bg-yellow-500 text-black text-center py-2 z-50"> <p className="text-sm font-medium"> You are currently offline. Some features may be limited. </p> </div> );}
Push Notifications
export class NotificationService { private static vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY; static async requestPermission(): Promise<boolean> { if (!('Notification' in window)) { console.warn('This browser does not support notifications'); return false; } if (Notification.permission === 'granted') { return true; } if (Notification.permission === 'denied') { return false; } const permission = await Notification.requestPermission(); return permission === 'granted'; } static async subscribeToPush(): Promise<PushSubscription | null> { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { console.warn('Push messaging is not supported'); return null; } try { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.urlBase64ToUint8Array(this.vapidPublicKey!) }); // Send subscription to your server await this.sendSubscriptionToServer(subscription); return subscription; } catch (error) { console.error('Failed to subscribe to push notifications:', error); return null; } } private static urlBase64ToUint8Array(base64String: string): Uint8Array { 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; } private static async sendSubscriptionToServer(subscription: PushSubscription): Promise<void> { await fetch('/api/push-subscription', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(subscription), }); } static async showNotification(title: string, options?: NotificationOptions): Promise<void> { if (await this.requestPermission()) { new Notification(title, { icon: '/icon-192x192.png', badge: '/badge-72x72.png', ...options, }); } }}
Install Prompt
import { useState, useEffect } 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 [showInstallPrompt, setShowInstallPrompt] = useState(false); useEffect(() => { const handleBeforeInstallPrompt = (e: Event) => { // Prevent the mini-infobar from appearing on mobile e.preventDefault(); // Stash the event so it can be triggered later setDeferredPrompt(e as BeforeInstallPromptEvent); setShowInstallPrompt(true); }; const handleAppInstalled = () => { console.log('PWA was installed'); setShowInstallPrompt(false); setDeferredPrompt(null); }; window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.addEventListener('appinstalled', handleAppInstalled); return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('appinstalled', handleAppInstalled); }; }, []); const handleInstallClick = async () => { if (!deferredPrompt) return; // Show the install prompt await deferredPrompt.prompt(); // Wait for the user to respond to the prompt const { outcome } = await deferredPrompt.userChoice; console.log(`User response to install prompt: ${outcome}`); // Clear the deferred prompt setDeferredPrompt(null); setShowInstallPrompt(false); }; const handleDismiss = () => { setShowInstallPrompt(false); }; if (!showInstallPrompt) return null; return ( <div className="fixed bottom-4 left-4 right-4 bg-blue-600 text-white p-4 rounded-lg shadow-lg z-50"> <div className="flex items-center justify-between"> <div> <h3 className="font-semibold">Install App</h3> <p className="text-sm opacity-90"> Add this app to your home screen for quick access! </p> </div> <div className="flex gap-2"> <button onClick={handleDismiss} className="px-3 py-1 text-sm border border-white/30 rounded hover:bg-white/10" > Later </button> <button onClick={handleInstallClick} className="px-3 py-1 text-sm bg-white text-blue-600 rounded hover:bg-gray-100" > Install </button> </div> </div> </div> );}
PWA Best Practices
- Ensure your app works offline with appropriate fallback content
- Implement effective caching strategies for different types of resources
- Provide clear feedback when the app is offline or syncing
- Design for different screen sizes and orientations
- Use HTTPS for all PWA features to work properly
- Optimize performance with lazy loading and code splitting
- Test on various devices and network conditions
- Implement proper error handling for offline scenarios
Background scripts for caching, offline functionality, and push notifications.
Configuration for home screen installation and app-like behavior.
Robust caching strategies for seamless offline experiences.
Fast loading times and smooth interactions across all devices.
“Progressive Web Apps bridge the gap between web and native applications, providing users with fast, engaging, and reliable experiences regardless of network conditions.”