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:
- Progressive - Works for every user, regardless of browser choice
- Responsive - Fits any form factor: desktop, mobile, tablet, etc.
- Connectivity independent - Works offline or with poor network conditions
- App-like - Feels like an app with app-style interactions and navigation
- Fresh - Always up-to-date thanks to service workers
- Safe - Served via HTTPS to prevent snooping
- Discoverable - Identifiable as an "application" by search engines
- Re-engageable - Makes re-engagement easy through features like push notifications
- Installable - Allows users to add apps to their home screen
- 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:
- A Web App Manifest
- Service Workers
- HTTPS
- Responsive design
- 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:
- First, install the web-push library:
npm install web-push
# or
yarn add web-push
- 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.
- 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`);
}
}
- 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>
);
}
- 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:
- Build and start your Next.js application in production mode:
npm run build
npm start
# or
yarn build
yarn start
- Open Chrome and navigate to your application
- Open DevTools (F12 or Ctrl+Shift+I)
- Go to the Lighthouse tab
- Select the "Progressive Web App" category
- 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!