We use cookies to enhance your experience on the site
CodeWorlds

Progressive Web Apps and Service Workers

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.

What are Progressive Web Apps?

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:

  1. Progressive - work for every user, regardless of the browser
  2. Responsive - adapt to different screen sizes
  3. Connectivity Independent - work offline or with a poor connection
  4. App-like - provide navigation and interactions like in native applications
  5. Fresh - always up to date thanks to Service Worker
  6. Safe - served over HTTPS
  7. Discoverable - identified as "applications" by search engines
  8. Re-engageable - enable re-engagement via push notifications
  9. Installable - can be installed on a device without an app store
  10. Linkable - easy sharing via URL

Service Workers - the foundation of PWA

Service Workers are JavaScript scripts that run in the background, independently of the application's main thread. They allow:

  • Caching resources for offline work
  • Intercepting and modifying network requests
  • Sending push notifications
  • Background synchronization
  • Application installation

Service Worker lifecycle

  1. Registration - registering the Service Worker
  2. Installation - installing and configuring the cache
  3. Activation - activating and clearing old caches
  4. Fetch - intercepting network requests
  5. Update - updating to a new version

PWA implementation in Next.js

1. Installing and configuring next-pwa

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-pwa

Update

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});

2. Application manifest

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}

3. Adding meta tags to HTML

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}

Custom Service Worker implementation

If you need more control over the Service Worker, you can create your own implementation:

1. Creating a Service Worker

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}

2. Registration Service Worker

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}

3. Usage in a component

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}

Advanced caching strategies

Different content types require different caching strategies:

1. Cache First - for static resources

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});

2. Network First - for dynamic content

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});

3. Stale While Revalidate - for frequently changing content

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});

Implementing offline functionality

1. Offline page

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}

2. Detecting online/offline state

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}

3. Connection status indicator component

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

Push notifications allow user re-engagement:

1. Subscription registration

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}

2. Handling push notifications in Service Worker

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});

3. API endpoint for sending notifications

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}

Installing the application (Add to Home Screen)

1. InstallPrompt component

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          >
8283          </button>
84        </div>
85      </div>
86    </div>
87  );
88}

Testing PWA

1. Lighthouse Audit

Lighthouse is a Google tool for PWA audits. You can run it:

  • In Chrome DevTools (Lighthouse tab)
  • From the command line:
    npm install -g lighthouse
  • Online: https://web.dev/measure/

2. PWA Builder

Microsoft PWA Builder (https://www.pwabuilder.com/) is a tool for testing and building PWAs.

3. Manual testing

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}

PWA best practices

1. Performance

  • Use appropriate caching strategies
  • Optimize bundle size
  • Implement lazy loading
  • Use Service Worker to preload critical resources

2. User Experience

  • Ensure smooth transitions between online/offline modes
  • Implement skeleton screens during loading
  • Add loading indicators
  • Provide clear connection status messages

3. Security

  • Always use HTTPS
  • Validate all input data
  • Implement Content Security Policy (CSP)
  • Regularly update the Service Worker

4. Accessibility

  • Provide screen reader support
  • Use appropriate color contrasts
  • Implement keyboard navigation
  • Add appropriate aria-labels

Summary

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:

  1. Service Worker - for caching and offline work
  2. Web App Manifest - for application metadata
  3. HTTPS - required for PWA
  4. Responsive Design - for different devices
  5. Push Notifications - for user engagement
  6. Install Prompts - for installation on a device

PWAs allow delivering a better user experience, increasing engagement, and improving application performance, especially in conditions of poor internet connection.

Go to CodeWorlds