Next.js

Progressive Web Apps with Next.js

30 min Lesson 35 of 40

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:

  1. Service worker with CacheFirst strategy for images
  2. NetworkFirst strategy for API calls
  3. Custom offline page with retry functionality
  4. Push notification system with subscription management
  5. Install prompt with custom UI
  6. Background sync for form submissions when offline
  7. Online/offline status indicator
  8. 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.