Progressive Web Applications (PWAs) combine the reach of web apps with native app features like offline support, push notifications, and home screen installation—all from a single codebase without app store approvals.
Creating a PWA
Create the web app manifest
Create app/manifest.ts to define your app’s metadata:import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: '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: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
}
}
Use a favicon generator to create icon sets and place them in public/. Implement push notifications
Create the main page component with push notification management:'use client'
import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser, sendNotification } from './actions'
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
}
function PushNotificationManager() {
const [isSupported, setIsSupported] = useState(false)
const [subscription, setSubscription] = useState<PushSubscription | null>(null)
const [message, setMessage] = useState('')
useEffect(() => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true)
registerServiceWorker()
}
}, [])
async function registerServiceWorker() {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
})
const sub = await registration.pushManager.getSubscription()
setSubscription(sub)
}
async function subscribeToPush() {
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
})
setSubscription(sub)
const serializedSub = JSON.parse(JSON.stringify(sub))
await subscribeUser(serializedSub)
}
async function unsubscribeFromPush() {
await subscription?.unsubscribe()
setSubscription(null)
await unsubscribeUser()
}
if (!isSupported) {
return <p>Push notifications are not supported in this browser.</p>
}
return (
<div>
<h3>Push Notifications</h3>
{subscription ? (
<>
<p>You are subscribed to push notifications.</p>
<button onClick={unsubscribeFromPush}>Unsubscribe</button>
<input
type="text"
placeholder="Enter notification message"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={() => sendNotification(message)}>Send Test</button>
</>
) : (
<>
<p>You are not subscribed to push notifications.</p>
<button onClick={subscribeToPush}>Subscribe</button>
</>
)}
</div>
)
}
function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false)
const [isStandalone, setIsStandalone] = useState(false)
useEffect(() => {
setIsIOS(/iPad|iPhone|iPod/.test(navigator.userAgent))
setIsStandalone(window.matchMedia('(display-mode: standalone)').matches)
}, [])
if (isStandalone) return null
return (
<div>
<h3>Install App</h3>
{isIOS && (
<p>Tap the share button and then "Add to Home Screen" to install this app.</p>
)}
</div>
)
}
export default function Page() {
return (
<div>
<PushNotificationManager />
<InstallPrompt />
</div>
)
}
Create Server Actions for notifications
'use server'
import webpush from 'web-push'
webpush.setVapidDetails(
'mailto:[email protected]',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
let subscription: PushSubscription | null = null
export async function subscribeUser(sub: PushSubscription) {
subscription = sub
// In production: store in database
return { success: true }
}
export async function unsubscribeUser() {
subscription = null
// In production: remove from database
return { success: true }
}
export async function sendNotification(message: string) {
if (!subscription) throw new Error('No subscription available')
try {
await webpush.sendNotification(
subscription,
JSON.stringify({
title: 'Test Notification',
body: message,
icon: '/icon.png',
})
)
return { success: true }
} catch (error) {
console.error('Error sending push notification:', error)
return { success: false, error: 'Failed to send notification' }
}
}
Generate VAPID keys
Install web-push globally and generate keys:npm install -g web-push
web-push generate-vapid-keys
Add the output to your .env file:NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
Create the service worker
self.addEventListener('push', function (event) {
if (event.data) {
const data = event.data.json()
const options = {
body: data.body,
icon: data.icon || '/icon.png',
badge: '/badge.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2',
},
}
event.waitUntil(self.registration.showNotification(data.title, options))
}
})
self.addEventListener('notificationclick', function (event) {
event.notification.close()
event.waitUntil(clients.openWindow('https://your-website.com'))
})
Add security headers
Configure security headers in next.config.js, especially for the service worker:module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
},
{
source: '/sw.js',
headers: [
{ key: 'Content-Type', value: 'application/javascript; charset=utf-8' },
{ key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' },
{ key: 'Content-Security-Policy', value: "default-src 'self'; script-src 'self'" },
],
},
]
},
}
Home screen installation
For a PWA to be installable, you need:
- A valid web app manifest (created in step 1)
- The site served over HTTPS
Modern browsers automatically show an install prompt when these criteria are met. For iOS, users must manually tap Share → Add to Home Screen.
Testing locally
To test push notifications locally:
next dev --experimental-https
- Accept notification permissions when prompted
- Ensure browser notifications are not globally disabled
Offline support
For offline functionality, use Serwist with Next.js. See their documentation for integration steps.
The Serwist plugin currently requires webpack configuration.