Progressive Web Apps (PWA) are modern web applications that combine the best features of web and native applications. Next.js offers excellent support for PWAs, allowing the creation of fast, reliable, and engaging applications that also work offline. In this module, we will learn how to implement PWAs in Next.js using Service Workers.
Progressive Web Apps are web applications that use modern web technologies to provide users with an experience similar to native applications. The key features of PWAs are:
Service Workers are JavaScript scripts that run in the background, independently of the application's main thread. They allow:
The easiest way to add PWA functionality to a Next.js application is to use the package
next-pwa:1npm install next-pwa
2# or
3yarn add next-pwaUpdate
next.config.js:1// next.config.js
2const withPWA = require('next-pwa')({
3 dest: 'public',
4 register: true,
5 skipWaiting: true,
6 disable: process.env.NODE_ENV === 'development', // Disable in development
7 runtimeCaching: [
8 {
9 urlPattern: /^https?.*/,
10 handler: 'NetworkFirst',
11 options: {
12 cacheName: 'offlineCache',
13 expiration: {
14 maxEntries: 200,
15 maxAgeSeconds: 24 * 60 * 60, // 24 hours
16 },
17 },
18 },
19 ],
20});
21
22module.exports = withPWA({
23 // Your standard Next.js configuration
24 reactStrictMode: true,
25 swcMinify: true,
26});The manifest is a JSON file that contains metadata about the PWA application. Create the file
public/manifest.json:1{
2 "name": "My PWA Application",
3 "short_name": "MyApp",
4 "description": "Example Progressive Web App built with Next.js",
5 "start_url": "/",
6 "display": "standalone",
7 "background_color": "#ffffff",
8 "theme_color": "#000000",
9 "orientation": "portrait-primary",
10 "icons": [
11 {
12 "src": "/icons/icon-72x72.png",
13 "sizes": "72x72",
14 "type": "image/png",
15 "purpose": "maskable any"
16 },
17 {
18 "src": "/icons/icon-96x96.png",
19 "sizes": "96x96",
20 "type": "image/png",
21 "purpose": "maskable any"
22 },
23 {
24 "src": "/icons/icon-128x128.png",
25 "sizes": "128x128",
26 "type": "image/png",
27 "purpose": "maskable any"
28 },
29 {
30 "src": "/icons/icon-144x144.png",
31 "sizes": "144x144",
32 "type": "image/png",
33 "purpose": "maskable any"
34 },
35 {
36 "src": "/icons/icon-152x152.png",
37 "sizes": "152x152",
38 "type": "image/png",
39 "purpose": "maskable any"
40 },
41 {
42 "src": "/icons/icon-192x192.png",
43 "sizes": "192x192",
44 "type": "image/png",
45 "purpose": "maskable any"
46 },
47 {
48 "src": "/icons/icon-384x384.png",
49 "sizes": "384x384",
50 "type": "image/png",
51 "purpose": "maskable any"
52 },
53 {
54 "src": "/icons/icon-512x512.png",
55 "sizes": "512x512",
56 "type": "image/png",
57 "purpose": "maskable any"
58 }
59 ],
60 "categories": ["productivity", "utilities"],
61 "screenshots": [
62 {
63 "src": "/screenshots/desktop.png",
64 "sizes": "1280x720",
65 "type": "image/png",
66 "form_factor": "wide"
67 },
68 {
69 "src": "/screenshots/mobile.png",
70 "sizes": "750x1334",
71 "type": "image/png",
72 "form_factor": "narrow"
73 }
74 ]
75}Update the main application layout to add the necessary meta tags:
1// app/layout.tsx
2import type { Metadata, Viewport } from 'next';
3
4export const metadata: Metadata = {
5 title: 'My PWA Application',
6 description: 'Example Progressive Web App',
7 generator: 'Next.js',
8 manifest: '/manifest.json',
9 keywords: ['nextjs', 'pwa', 'react'],
10 authors: [
11 { name: 'Your Team' },
12 ],
13 creator: 'Your Team',
14 formatDetection: {
15 email: false,
16 address: false,
17 telephone: false,
18 },
19 metadataBase: new URL('https://your-domain.com'),
20 alternates: {
21 canonical: '/',
22 },
23 openGraph: {
24 type: 'website',
25 siteName: 'My PWA Application',
26 title: {
27 default: 'My PWA Application',
28 template: '%s | My PWA Application',
29 },
30 description: 'Example Progressive Web App',
31 },
32 twitter: {
33 card: 'summary',
34 title: {
35 default: 'My PWA Application',
36 template: '%s | My PWA Application',
37 },
38 description: 'Example Progressive Web App',
39 },
40 appleWebApp: {
41 title: 'My PWA Application',
42 statusBarStyle: 'default',
43 capable: true,
44 },
45};
46
47export const viewport: Viewport = {
48 themeColor: [
49 { media: '(prefers-color-scheme: light)', color: '#ffffff' },
50 { media: '(prefers-color-scheme: dark)', color: '#000000' },
51 ],
52 minimumScale: 1,
53 initialScale: 1,
54 width: 'device-width',
55 shrinkToFit: false,
56 viewportFit: 'cover',
57};
58
59export default function RootLayout({
60 children,
61}: {
62 children: React.ReactNode;
63}) {
64 return (
65 <html lang="pl">
66 <head>
67 <link rel="icon" href="/favicon.ico" />
68 <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
69 </head>
70 <body>
71 {children}
72 </body>
73 </html>
74 );
75}If you need more control over the Service Worker, you can create your own implementation:
1// public/sw.js
2const CACHE_NAME = 'my-app-v1';
3const urlsToCache = [
4 '/',
5 '/static/js/bundle.js',
6 '/static/css/main.css',
7 '/manifest.json',
8];
9
10// Install event - resource caching
11self.addEventListener('install', (event) => {
12 event.waitUntil(
13 caches.open(CACHE_NAME)
14 .then((cache) => {
15 console.log('Cache opened');
16 return cache.addAll(urlsToCache);
17 })
18 .then(() => {
19 // Forces activation of the new Service Worker
20 return self.skipWaiting();
21 })
22 );
23});
24
25// Activate event - clearing old caches
26self.addEventListener('activate', (event) => {
27 event.waitUntil(
28 caches.keys().then((cacheNames) => {
29 return Promise.all(
30 cacheNames.map((cacheName) => {
31 if (cacheName !== CACHE_NAME) {
32 console.log('Removing old cache:', cacheName);
33 return caches.delete(cacheName);
34 }
35 })
36 );
37 }).then(() => {
38 // Take control over all clients
39 return self.clients.claim();
40 })
41 );
42});
43
44// Fetch event - cache-first strategy
45self.addEventListener('fetch', (event) => {
46 event.respondWith(
47 caches.match(event.request)
48 .then((response) => {
49 // Return from cache if available
50 if (response) {
51 return response;
52 }
53
54 // Otherwise fetch from the network
55 return fetch(event.request).then((response) => {
56 // Check if we received a valid response
57 if (!response || response.status !== 200 || response.type !== 'basic') {
58 return response;
59 }
60
61 // Clone the response
62 const responseToCache = response.clone();
63
64 caches.open(CACHE_NAME)
65 .then((cache) => {
66 cache.put(event.request, responseToCache);
67 });
68
69 return response;
70 }).catch(() => {
71 // If the request failed, return the offline page
72 if (event.request.destination === 'document') {
73 return caches.match('/offline.html');
74 }
75 });
76 })
77 );
78});
79
80// Background sync
81self.addEventListener('sync', (event) => {
82 if (event.tag === 'background-sync') {
83 event.waitUntil(doBackgroundSync());
84 }
85});
86
87function doBackgroundSync() {
88 // Background synchronization implementation
89 return fetch('/api/sync')
90 .then(response => response.json())
91 .then(data => {
92 console.log('Background sync completed:', data);
93 })
94 .catch(err => {
95 console.error('Background sync failed:', err);
96 });
97}1// lib/serviceWorker.ts
2export function registerSW() {
3 if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
4 window.addEventListener('load', () => {
5 navigator.serviceWorker.register('/sw.js')
6 .then((registration) => {
7 console.log('SW registered: ', registration);
8
9 // Checking for updates
10 registration.addEventListener('updatefound', () => {
11 const newWorker = registration.installing;
12 if (newWorker) {
13 newWorker.addEventListener('statechange', () => {
14 if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
15 // A new Service Worker is available
16 showUpdateAvailableNotification();
17 }
18 });
19 }
20 });
21 })
22 .catch((error) => {
23 console.log('SW registration failed: ', error);
24 });
25 });
26 }
27}
28
29export function unregisterSW() {
30 if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
31 navigator.serviceWorker.ready.then((registration) => {
32 registration.unregister();
33 });
34 }
35}
36
37function showUpdateAvailableNotification() {
38 // Show notification about available update
39 if (confirm('A new version of the application is available. Would you like to update it?')) {
40 window.location.reload();
41 }
42}1// app/page.tsx
2'use client';
3
4import { useEffect } from 'react';
5import { registerSW } from '@/lib/serviceWorker';
6
7export default function HomePage() {
8 useEffect(() => {
9 registerSW();
10 }, []);
11
12 return (
13 <div>
14 <h1>My PWA Application</h1>
15 <p>Welcome to the Progressive Web App!</p>
16 </div>
17 );
18}Different content types require different caching strategies:
1// Cache First strategy
2self.addEventListener('fetch', (event) => {
3 if (event.request.destination === 'image' ||
4 event.request.destination === 'style' ||
5 event.request.destination === 'script') {
6 event.respondWith(
7 caches.match(event.request).then((cachedResponse) => {
8 return cachedResponse || fetch(event.request).then((response) => {
9 const responseClone = response.clone();
10 caches.open(CACHE_NAME).then((cache) => {
11 cache.put(event.request, responseClone);
12 });
13 return response;
14 });
15 })
16 );
17 }
18});1// Network First strategy
2self.addEventListener('fetch', (event) => {
3 if (event.request.url.includes('/api/')) {
4 event.respondWith(
5 fetch(event.request)
6 .then((response) => {
7 const responseClone = response.clone();
8 caches.open(CACHE_NAME).then((cache) => {
9 cache.put(event.request, responseClone);
10 });
11 return response;
12 })
13 .catch(() => {
14 return caches.match(event.request);
15 })
16 );
17 }
18});1// Stale While Revalidate strategy
2self.addEventListener('fetch', (event) => {
3 if (event.request.url.includes('/news/')) {
4 event.respondWith(
5 caches.match(event.request).then((cachedResponse) => {
6 const fetchPromise = fetch(event.request).then((networkResponse) => {
7 caches.open(CACHE_NAME).then((cache) => {
8 cache.put(event.request, networkResponse.clone());
9 });
10 return networkResponse;
11 });
12
13 return cachedResponse || fetchPromise;
14 })
15 );
16 }
17});Create a dedicated offline page:
1// app/offline/page.tsx
2export default function OfflinePage() {
3 return (
4 <div className="min-h-screen flex items-center justify-center bg-gray-100">
5 <div className="text-center">
6 <div className="mb-4">
7 <svg className="mx-auto h-16 w-16 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
8 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M12 2.25a9.75 9.75 0 1 0 0 19.5 9.75 9.75 0 0 0 0-19.5Z" />
9 </svg>
10 </div>
11 <h1 className="text-2xl font-bold text-gray-900 mb-2">You are offline</h1>
12 <p className="text-gray-600 mb-4">
13 We cannot connect to the internet. Check your connection and try again.
14 </p>
15 <button
16 onClick={() => window.location.reload()}
17 className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded"
18 >
19 Try again
20 </button>
21 </div>
22 </div>
23 );
24}1// hooks/useOnlineStatus.ts
2import { useState, useEffect } from 'react';
3
4export function useOnlineStatus() {
5 const [isOnline, setIsOnline] = useState(true);
6
7 useEffect(() => {
8 function handleOnline() {
9 setIsOnline(true);
10 }
11
12 function handleOffline() {
13 setIsOnline(false);
14 }
15
16 window.addEventListener('online', handleOnline);
17 window.addEventListener('offline', handleOffline);
18
19 // Check initial state
20 setIsOnline(navigator.onLine);
21
22 return () => {
23 window.removeEventListener('online', handleOnline);
24 window.removeEventListener('offline', handleOffline);
25 };
26 }, []);
27
28 return isOnline;
29}1// components/OnlineStatusIndicator.tsx
2'use client';
3
4import { useOnlineStatus } from '@/hooks/useOnlineStatus';
5
6export default function OnlineStatusIndicator() {
7 const isOnline = useOnlineStatus();
8
9 if (isOnline) {
10 return null; // Don't show anything when there is a connection
11 }
12
13 return (
14 <div className="fixed top-0 left-0 right-0 bg-red-500 text-white text-center py-2 z-50">
15 <span className="text-sm font-medium">
16 📡 No internet connection - you are operating in offline mode
17 </span>
18 </div>
19 );
20}Push notifications allow user re-engagement:
1// lib/notifications.ts
2
3// Function to convert VAPID key
4function urlBase64ToUint8Array(base64String: string) {
5 const padding = '='.repeat((4 - base64String.length % 4) % 4);
6 const base64 = (base64String + padding)
7 .replace(/-/g, '+')
8 .replace(/_/g, '/');
9
10 const rawData = window.atob(base64);
11 const outputArray = new Uint8Array(rawData.length);
12
13 for (let i = 0; i < rawData.length; ++i) {
14 outputArray[i] = rawData.charCodeAt(i);
15 }
16 return outputArray;
17}
18
19export async function subscribeToPushNotifications() {
20 if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
21 throw new Error('Push notifications are not supported');
22 }
23
24 const registration = await navigator.serviceWorker.ready;
25
26 // Check if a subscription already exists
27 let subscription = await registration.pushManager.getSubscription();
28
29 if (!subscription) {
30 const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
31 if (!vapidPublicKey) {
32 throw new Error('VAPID public key is not configured');
33 }
34
35 subscription = await registration.pushManager.subscribe({
36 userVisibleOnly: true,
37 applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
38 });
39 }
40
41 // Send subscription to the server
42 await fetch('/api/notifications/subscribe', {
43 method: 'POST',
44 headers: {
45 'Content-Type': 'application/json',
46 },
47 body: JSON.stringify(subscription),
48 });
49
50 return subscription;
51}
52
53export async function unsubscribeFromPushNotifications() {
54 const registration = await navigator.serviceWorker.ready;
55 const subscription = await registration.pushManager.getSubscription();
56
57 if (subscription) {
58 await subscription.unsubscribe();
59
60 // Notify the server about subscription cancellation
61 await fetch('/api/notifications/unsubscribe', {
62 method: 'POST',
63 headers: {
64 'Content-Type': 'application/json',
65 },
66 body: JSON.stringify(subscription),
67 });
68 }
69}1// public/sw.js (addition to previous code)
2
3// Handling push notifications
4self.addEventListener('push', (event) => {
5 const options = {
6 body: 'You have a new message!',
7 icon: '/icons/icon-192x192.png',
8 badge: '/icons/badge-72x72.png',
9 vibrate: [100, 50, 100],
10 data: {
11 dateOfArrival: Date.now(),
12 primaryKey: 1
13 },
14 actions: [
15 {
16 action: 'explore',
17 title: 'See more',
18 icon: '/icons/checkmark.png'
19 },
20 {
21 action: 'close',
22 title: 'Close',
23 icon: '/icons/xmark.png'
24 }
25 ]
26 };
27
28 if (event.data) {
29 const data = event.data.json();
30 options.body = data.body || options.body;
31 options.data = { ...options.data, ...data.data };
32 }
33
34 event.waitUntil(
35 self.registration.showNotification('My PWA Application', options)
36 );
37});
38
39// Handling notification click
40self.addEventListener('notificationclick', (event) => {
41 event.notification.close();
42
43 if (event.action === 'explore') {
44 // Open a specific page
45 event.waitUntil(
46 clients.openWindow('/notifications')
47 );
48 } else if (event.action === 'close') {
49 // Close the notification
50 return;
51 } else {
52 // Default action - open the main page
53 event.waitUntil(
54 clients.openWindow('/')
55 );
56 }
57});1// app/api/notifications/send/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import webpush from 'web-push';
4import { prisma } from '@/lib/prisma';
5
6// VAPID configuration
7webpush.setVapidDetails(
8 'mailto:your-email@example.com',
9 process.env.VAPID_PUBLIC_KEY!,
10 process.env.VAPID_PRIVATE_KEY!
11);
12
13export async function POST(request: NextRequest) {
14 try {
15 const { title, body, url } = await request.json();
16
17 // Fetch all subscriptions from the database
18 const subscriptions = await prisma.pushSubscription.findMany();
19
20 const payload = JSON.stringify({
21 title,
22 body,
23 url,
24 icon: '/icons/icon-192x192.png',
25 });
26
27 // Send notifications to all subscriptions
28 const promises = subscriptions.map(async (subscription) => {
29 try {
30 await webpush.sendNotification(
31 {
32 endpoint: subscription.endpoint,
33 keys: {
34 p256dh: subscription.p256dh,
35 auth: subscription.auth,
36 },
37 },
38 payload
39 );
40 } catch (error) {
41 console.error('Error sending notification:', error);
42
43 // If the subscription is invalid, remove it
44 if (error.statusCode === 410) {
45 await prisma.pushSubscription.delete({
46 where: { id: subscription.id },
47 });
48 }
49 }
50 });
51
52 await Promise.all(promises);
53
54 return NextResponse.json({ success: true });
55 } catch (error) {
56 console.error('Error sending notifications:', error);
57 return NextResponse.json(
58 { error: 'Failed to send notifications' },
59 { status: 500 }
60 );
61 }
62}1// components/InstallPrompt.tsx
2'use client';
3
4import { useState, useEffect } from 'react';
5
6interface BeforeInstallPromptEvent extends Event {
7 readonly platforms: string[];
8 readonly userChoice: Promise<{
9 outcome: 'accepted' | 'dismissed';
10 platform: string;
11 }>;
12 prompt(): Promise<void>;
13}
14
15export default function InstallPrompt() {
16 const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
17 const [showInstallButton, setShowInstallButton] = useState(false);
18
19 useEffect(() => {
20 const handler = (e: Event) => {
21 // Prevent automatic prompt display
22 e.preventDefault();
23 setDeferredPrompt(e as BeforeInstallPromptEvent);
24 setShowInstallButton(true);
25 };
26
27 window.addEventListener('beforeinstallprompt', handler);
28
29 // Check if the application is already installed
30 window.addEventListener('appinstalled', () => {
31 console.log('PWA has been installed');
32 setShowInstallButton(false);
33 setDeferredPrompt(null);
34 });
35
36 return () => {
37 window.removeEventListener('beforeinstallprompt', handler);
38 };
39 }, []);
40
41 const handleInstallClick = async () => {
42 if (!deferredPrompt) return;
43
44 // Show the installation prompt
45 deferredPrompt.prompt();
46
47 // Wait for user choice
48 const { outcome } = await deferredPrompt.userChoice;
49
50 if (outcome === 'accepted') {
51 console.log('User accepted the installation');
52 } else {
53 console.log('User rejected the installation');
54 }
55
56 setDeferredPrompt(null);
57 setShowInstallButton(false);
58 };
59
60 if (!showInstallButton) {
61 return null;
62 }
63
64 return (
65 <div className="fixed bottom-4 right-4 bg-blue-500 text-white p-4 rounded-lg shadow-lg">
66 <div className="flex items-center space-x-3">
67 <div>
68 <h3 className="font-semibold">Install application</h3>
69 <p className="text-sm opacity-90">Add to home screen for faster access</p>
70 </div>
71 <div className="flex space-x-2">
72 <button
73 onClick={handleInstallClick}
74 className="bg-white text-blue-500 px-3 py-1 rounded text-sm font-medium"
75 >
76 Install
77 </button>
78 <button
79 onClick={() => setShowInstallButton(false)}
80 className="text-white opacity-75 hover:opacity-100"
81 >
82 ✕
83 </button>
84 </div>
85 </div>
86 </div>
87 );
88}Lighthouse is a Google tool for PWA audits. You can run it:
npm install -g lighthouseMicrosoft PWA Builder (https://www.pwabuilder.com/) is a tool for testing and building PWAs.
1// utils/pwa-test.ts
2export function testPWAFeatures() {
3 const results = {
4 serviceWorker: 'serviceWorker' in navigator,
5 pushNotifications: 'PushManager' in window,
6 notifications: 'Notification' in window,
7 backgroundSync: 'serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype,
8 installPrompt: true, // Detected dynamically
9 offline: 'onLine' in navigator,
10 };
11
12 console.table(results);
13 return results;
14}Progressive Web Apps are a powerful technology that allows creating web applications with functionality close to native applications. Next.js with the next-pwa package or a custom Service Worker implementation offers all the tools needed to create a fully functional PWA.
Key elements of a PWA implementation in Next.js:
PWAs allow delivering a better user experience, increasing engagement, and improving application performance, especially in conditions of poor internet connection.