Back to articles
Next.js

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.

13 mins read
PWANext.jsService WorkersOffline

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.

code
npm install next-pwa workbox-webpack-plugin
JavaScriptnext.config.js
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

JSONpublic/manifest.json
{  "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

JavaScriptpublic/sw.js
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

TypeScripthooks/useOfflineStatus.ts
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;}
Reactcomponents/OfflineIndicator.tsx
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

TypeScriptutils/notifications.ts
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

Reactcomponents/InstallPrompt.tsx
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
Service Workers

Background scripts for caching, offline functionality, and push notifications.

App Manifest

Configuration for home screen installation and app-like behavior.

Offline Support

Robust caching strategies for seamless offline experiences.

Performance

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.