Building Progressive Web Apps with Next.js

Building Progressive Web Apps with Next.js

Building Progressive Web Apps with Next.js

Progressive Web Apps (PWAs) combine the best of web and mobile applications. They're fast, reliable, engaging, and work offline. In this guide, we'll explore how to build PWAs using Next.js, a powerful React framework that makes creating high-performance web applications straightforward.

What Makes a PWA?

Before diving into implementation, let's understand what makes an application a PWA:

  1. Progressive - Works for every user, regardless of browser choice
  2. Responsive - Fits any form factor: desktop, mobile, tablet, etc.
  3. Connectivity independent - Works offline or with poor network conditions
  4. App-like - Feels like an app with app-style interactions and navigation
  5. Fresh - Always up-to-date thanks to service workers
  6. Safe - Served via HTTPS to prevent snooping
  7. Discoverable - Identifiable as an "application" by search engines
  8. Re-engageable - Makes re-engagement easy through features like push notifications
  9. Installable - Allows users to add apps to their home screen
  10. Linkable - Easily shareable via URL

Setting Up Next.js for PWA

To transform a Next.js application into a PWA, we need to add a few key components:

  1. A Web App Manifest
  2. Service Workers
  3. HTTPS
  4. Responsive design
  5. Offline support

Let's go through each step:

1. Adding a Web App Manifest

The Web App Manifest is a JSON file that provides information about your web application. It tells the browser about your app and how it should behave when installed on the user's device.

Create a public/manifest.json file in your Next.js project:

{
  "name": "My Next.js PWA",
  "short_name": "NextPWA",
  "description": "A Progressive Web App built with Next.js",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#000000",
  "icons": [
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

Then, link to this manifest in your pages/_document.js file:

import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        <link rel="manifest" href="/manifest.json" />
        <meta name="theme-color" content="#000000" />
        <link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
        <meta name="apple-mobile-web-app-capable" content="yes" />
        <meta name="apple-mobile-web-app-status-bar-style" content="default" />
        <meta name="apple-mobile-web-app-title" content="My Next.js PWA" />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

2. Adding Service Workers

Service workers are scripts that run in the background, separate from the web page. They enable features like offline support, push notifications, and background sync.

The easiest way to add service workers to a Next.js application is by using the next-pwa package:

npm install next-pwa
# or
yarn add next-pwa

Update your next.config.js file:

const withPWA = require('next-pwa')({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  register: true,
  skipWaiting: true,
});

module.exports = withPWA({
  // Your existing Next.js config
});

This configuration will:

  • Generate a service worker in the public directory
  • Disable service worker generation in development mode
  • Automatically register the service worker
  • Skip the waiting phase, making updates available immediately

3. Ensuring HTTPS

PWAs require HTTPS to work properly. During development, Next.js serves your app over HTTP on localhost, which is considered secure. For production, you'll need to ensure your app is served over HTTPS.

If you're deploying to Vercel, Netlify, or similar platforms, HTTPS is provided automatically. If you're using a custom server, you'll need to set up HTTPS yourself.

4. Implementing Responsive Design

Next.js doesn't enforce any specific approach to styling, so you can use your preferred method for creating responsive designs. Here's an example using CSS modules:

// pages/index.js
import styles from '../styles/Home.module.css';

export default function Home() {
  return (
    <div className={styles.container}>
      <main className={styles.main}>
        <h1 className={styles.title}>Welcome to My PWA</h1>
        <p className={styles.description}>Get started by editing your app</p>
      </main>
    </div>
  );
}
/* styles/Home.module.css */
.container {
  min-height: 100vh;
  padding: 0 1rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

.main {
  padding: 5rem 0;
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  width: 100%;
}

.title {
  margin: 0;
  line-height: 1.15;
  font-size: 4rem;
  text-align: center;
}

.description {
  line-height: 1.5;
  font-size: 1.5rem;
  text-align: center;
}

/* Responsive styles */
@media (max-width: 600px) {
  .title {
    font-size: 2.5rem;
  }
  
  .description {
    font-size: 1.2rem;
  }
}

Alternatively, you can use a CSS framework like Tailwind CSS or a component library like Material-UI for responsive design.

5. Adding Offline Support

With service workers in place, we can add offline support by caching assets and pages. The next-pwa package handles most of this for us, but we can customize the behavior by creating a custom service worker.

Create a file called worker/index.js in your project:

// This service worker can be customized!
self.addEventListener('install', (event) => {
  console.log('Service worker installed');
});

self.addEventListener('activate', (event) => {
  console.log('Service worker activated');
});

// Custom fetch handler for offline support
self.addEventListener('fetch', (event) => {
  const { request } = event;
  
  // Skip cross-origin requests
  if (!request.url.startsWith(self.location.origin)) {
    return;
  }
  
  event.respondWith(
    caches.match(request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse;
      }
      
      return fetch(request).then((response) => {
        // Don't cache non-successful responses
        if (!response || response.status !== 200 || response.type !== 'basic') {
          return response;
        }
        
        // Clone the response since it can only be consumed once
        const responseToCache = response.clone();
        
        caches.open('next-pwa-cache').then((cache) => {
          cache.put(request, responseToCache);
        });
        
        return response;
      });
    })
  );
});

Update your next.config.js to use this custom worker:

const withPWA = require('next-pwa')({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  register: true,
  skipWaiting: true,
  swSrc: 'worker/index.js',
});

module.exports = withPWA({
  // Your existing Next.js config
});

Creating an Offline Fallback Page

To provide a better user experience when offline, let's create a custom offline page:

// pages/offline.js
import Head from 'next/head';
import styles from '../styles/Home.module.css';

export default function Offline() {
  return (
    <div className={styles.container}>
      <Head>
        <title>Offline - My PWA</title>
      </Head>
      <main className={styles.main}>
        <h1 className={styles.title}>You are offline</h1>
        <p className={styles.description}>
          Please check your internet connection and try again.
        </p>
      </main>
    </div>
  );
}

Update your service worker to serve this page when offline:

// In worker/index.js

// Add this to your fetch event handler
self.addEventListener('fetch', (event) => {
  const { request } = event;
  
  // Skip cross-origin requests
  if (!request.url.startsWith(self.location.origin)) {
    return;
  }
  
  event.respondWith(
    caches.match(request).then((cachedResponse) => {
      if (cachedResponse) {
        return cachedResponse;
      }
      
      return fetch(request).catch(() => {
        // If the fetch fails (offline), serve the offline page
        if (request.mode === 'navigate') {
          return caches.match('/offline');
        }
      });
    })
  );
});

Adding Install Prompts

To encourage users to install your PWA, you can add a custom install prompt. First, create a component for the prompt:

// components/InstallPWA.js
import { useState, useEffect } from 'react';
import styles from '../styles/InstallPWA.module.css';

export default function InstallPWA() {
  const [supportsPWA, setSupportsPWA] = useState(false);
  const [promptInstall, setPromptInstall] = useState(null);

  useEffect(() => {
    const handler = (e) => {
      e.preventDefault();
      setSupportsPWA(true);
      setPromptInstall(e);
    };
    
    window.addEventListener('beforeinstallprompt', handler);

    return () => window.removeEventListener('beforeinstallprompt', handler);
  }, []);

  const onClick = (evt) => {
    evt.preventDefault();
    if (!promptInstall) {
      return;
    }
    promptInstall.prompt();
  };
  
  if (!supportsPWA) {
    return null;
  }
  
  return (
    <div className={styles.installContainer}>
      <button
        className={styles.installButton}
        id="setup_button"
        aria-label="Install app"
        title="Install app"
        onClick={onClick}
      >
        Install App
      </button>
    </div>
  );
}

Add some styles:

/* styles/InstallPWA.module.css */
.installContainer {
  position: fixed;
  bottom: 1rem;
  right: 1rem;
  z-index: 1000;
}

.installButton {
  background-color: #0070f3;
  color: white;
  border: none;
  border-radius: 4px;
  padding: 0.75rem 1.5rem;
  font-size: 1rem;
  cursor: pointer;
  box-shadow: 0 4px 14px 0 rgba(0, 118, 255, 0.39);
  transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
}

.installButton:hover {
  background: rgba(0, 118, 255, 0.9);
  box-shadow: 0 6px 20px rgba(0, 118, 255, 0.23);
}

Then, include this component in your layout or specific pages:

// components/Layout.js
import Head from 'next/head';
import InstallPWA from './InstallPWA';

export default function Layout({ children, title = 'My PWA' }) {
  return (
    <div>
      <Head>
        <title>{title}</title>
        <meta name="description" content="A Progressive Web App built with Next.js" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </Head>
      <main>{children}</main>
      <InstallPWA />
    </div>
  );
}

Adding Push Notifications

Push notifications are a powerful way to re-engage users. Here's how to implement them in your Next.js PWA:

  1. First, install the web-push library:
npm install web-push
# or
yarn add web-push
  1. Generate VAPID keys for your application:
// scripts/generate-vapid-keys.js
const webpush = require('web-push');

const vapidKeys = webpush.generateVAPIDKeys();

console.log('Public Key:', vapidKeys.publicKey);
console.log('Private Key:', vapidKeys.privateKey);

Run this script with node scripts/generate-vapid-keys.js and save the keys securely.

  1. Create an API route to handle push notification subscriptions:
// pages/api/subscribe.js
import webpush from 'web-push';

// Configure web-push with your VAPID keys
webpush.setVapidDetails(
  'mailto:your-email@example.com',
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

// In a real app, you would store subscriptions in a database
let subscriptions = [];

export default async function handler(req, res) {
  if (req.method === 'POST') {
    const subscription = req.body;
    
    // Store the subscription
    subscriptions.push(subscription);
    
    // Send a test notification
    try {
      await webpush.sendNotification(
        subscription,
        JSON.stringify({
          title: 'Welcome to My PWA',
          body: 'You have successfully subscribed to push notifications!',
          icon: '/icons/icon-192x192.png',
        })
      );
      
      res.status(201).json({ message: 'Subscription added successfully' });
    } catch (error) {
      console.error('Error sending notification:', error);
      res.status(500).json({ error: 'Subscription failed' });
    }
  } else {
    res.setHeader('Allow', ['POST']);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}
  1. Create a component to handle push notification subscriptions:
// components/PushNotification.js
import { useState, useEffect } from 'react';
import styles from '../styles/PushNotification.module.css';

export default function PushNotification() {
  const [isSubscribed, setIsSubscribed] = useState(false);
  const [subscription, setSubscription] = useState(null);
  const [registration, setRegistration] = useState(null);

  useEffect(() => {
    if (typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window) {
      // Check if service worker is registered
      navigator.serviceWorker.ready.then((reg) => {
        setRegistration(reg);
        
        // Check if already subscribed
        reg.pushManager.getSubscription().then((sub) => {
          if (sub) {
            setIsSubscribed(true);
            setSubscription(sub);
          }
        });
      });
    }
  }, []);

  const subscribeUser = async () => {
    try {
      const sub = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY),
      });
      
      setSubscription(sub);
      setIsSubscribed(true);
      
      // Send the subscription to your server
      await fetch('/api/subscribe', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(sub),
      });
      
      console.log('Subscription successful');
    } catch (error) {
      console.error('Failed to subscribe:', error);
    }
  };

  const unsubscribeUser = async () => {
    try {
      const success = await subscription.unsubscribe();
      
      if (success) {
        setIsSubscribed(false);
        setSubscription(null);
        console.log('Unsubscribed successfully');
      }
    } catch (error) {
      console.error('Error unsubscribing:', error);
    }
  };

  // Convert base64 string to Uint8Array for applicationServerKey
  function urlBase64ToUint8Array(base64String) {
    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;
  }

  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    return <p>Push notifications are not supported in this browser.</p>;
  }

  return (
    <div className={styles.container}>
      <h2>Push Notifications</h2>
      <button
        className={styles.button}
        onClick={isSubscribed ? unsubscribeUser : subscribeUser}
      >
        {isSubscribed ? 'Unsubscribe from Notifications' : 'Subscribe to Notifications'}
      </button>
    </div>
  );
}
  1. Update your service worker to handle push events:
// In worker/index.js

// Handle push events
self.addEventListener('push', (event) => {
  if (!event.data) return;
  
  const data = event.data.json();
  
  const options = {
    body: data.body,
    icon: data.icon,
    badge: '/icons/badge-icon.png',
    vibrate: [100, 50, 100],
    data: {
      url: data.url || '/',
    },
  };
  
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  
  const url = event.notification.data.url;
  
  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((windowClients) => {
      // Check if there is already a window/tab open with the target URL
      for (let i = 0; i < windowClients.length; i++) {
        const client = windowClients[i];
        // If so, just focus it.
        if (client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      // If not, open a new window/tab
      if (clients.openWindow) {
        return clients.openWindow(url);
      }
    })
  );
});

Testing Your PWA

To test your PWA, you can use Lighthouse, a tool built into Chrome DevTools:

  1. Build and start your Next.js application in production mode:
npm run build
npm start
# or
yarn build
yarn start
  1. Open Chrome and navigate to your application
  2. Open DevTools (F12 or Ctrl+Shift+I)
  3. Go to the Lighthouse tab
  4. Select the "Progressive Web App" category
  5. Click "Generate report"

Lighthouse will analyze your application and provide a score along with suggestions for improvement.

Deploying Your PWA

Deploying a Next.js PWA is similar to deploying any Next.js application. Here are some popular options:

Vercel (Recommended for Next.js)

npm install -g vercel
vercel

Netlify

Create a netlify.toml file in your project root:

[build]
  command = "npm run build"
  publish = ".next"

[[plugins]]
  package = "@netlify/plugin-nextjs"

Then deploy using the Netlify CLI or connect your repository to Netlify.

Conclusion

Building a PWA with Next.js combines the power of React's component-based architecture with the performance and reliability benefits of Progressive Web Apps. By following the steps in this guide, you can create web applications that load quickly, work offline, and provide an app-like experience to your users.

Remember that PWAs are not just about technical implementation—they're about creating fast, reliable, and engaging experiences for your users. Focus on performance, accessibility, and user experience to make the most of what PWAs have to offer.

Happy coding!