Transform Your Next.js 16 App into a Powerful PWA
Step-by-step guide on converting your Next.js 16 app into a production-ready PWA with push notifications.

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I was building a time tracking application for delivery drivers when I realized the core problem: employees needed a native app experience on their phones, home screen installation, persistent notifications, offline capability—but we didn't have resources to build separate iOS and Android apps. That's when I discovered that converting our existing Next.js web application into a PWA solved the entire problem. A year later, we've deployed this to hundreds of drivers, and it performs just as well as a native app.
This guide walks you through exactly how to turn your Next.js 16 application into a production-ready PWA with Web Push notifications. By the end, you'll understand the complete picture: from manifest files to service workers to push subscription management.
Why PWAs Matter for Business Applications
Before diving into code, let's establish why this approach is valuable. A Progressive Web Application gives your business app several advantages:
When your employees install the PWA on their home screen, it launches in standalone mode—no browser chrome, no address bar. To the user, it looks and feels like a native app. For business applications especially, this matters because your employees spend hours inside the app. They deserve a polished experience.
Second, PWAs enable push notifications. Whether you're notifying employees about new deliveries, shift changes, or time tracking updates, push notifications keep users engaged without them needing to actively check the app. We're talking about notifications that persist on their phone's home screen and notification panel, just like a native app.
Third, there's no app store bottleneck. You deploy updates to your server, and users instantly get the latest version on their next app launch. No waiting for app store review processes. For business applications where speed matters, this is transformative.
Step 1: Create the Web App Manifest
A web app manifest is a JSON file that tells the browser how to display your PWA. It controls the app name, icons, colors, and startup behavior.
Create a new file at src/app/manifest.ts:
// File: src/app/manifest.ts
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Boneks',
short_name: 'Boneks',
description: 'Time tracking and delivery management for mobile teams',
start_url: 'https://www.yourdomain.com/admin',
scope: 'https://www.yourdomain.com/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#10b981',
orientation: 'portrait-primary',
icons: [
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/icon-192x192-maskable.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable',
},
],
}
}
This manifest tells the browser several things. The display: 'standalone' property makes the app launch without browser UI. The start_url points to where users should land when they open the app from their home screen. The icons are what appear on the user's home screen—you'll need to generate these using a tool like favicon generator.
Next.js 16 automatically links this manifest to your HTML, so you don't need to manually add any link tags. That's one of the nice improvements in the newer versions.
Step 2: Generate VAPID Keys for Web Push
Web Push notifications require authentication between your server and the push service. This is where VAPID (Voluntary Application Server Identification) keys come in. They're a pair of public and private keys that identify your application to push services like FCM and APNs.
Generate them using the web-push CLI:
npm install -g web-push web-push generate-vapid-keys
This outputs something like:
Public Key:
BJJ1YuYqWLHm0blexdUaZ1yAT7PGuEgWxVbbR-XB7RsNoNCg6ljkTUrC1XXwN_1j6ybqMB8ZrkhQODCtfjcRvXo
Private Key:
39i_a0W-qGR6HejMHtsvHcsx2CQnWrV_OS7u7otL9SI
Add both to your .env file:
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BJJ1YuYqWLHm0blexdUaZ1yAT7PGuEgWxVbbR-XB7RsNoNCg6ljkTUrC1XXwN_1j6ybqMB8ZrkhQODCtfjcRvXo
VAPID_PRIVATE_KEY=39i_a0W-qGR6HejMHtsvHcsx2CQnWrV_OS7u7otL9SI
The public key is prefixed with NEXT_PUBLIC_ because it needs to be accessible in the browser. The private key stays secret on your server. These keys ensure that only your server can send notifications to your app, preventing malicious third parties from impersonating your application.
Step 3: Install Dependencies
You'll need two packages to handle Web Push:
npm install web-push npm install -D @types/web-push
The web-push package handles sending notifications from your server, and @types/web-push provides TypeScript definitions.
Step 4: Create the Service Worker
A Service Worker is JavaScript that runs in the background, separate from your main app thread. It intercepts push events from the notification service and displays them to the user.
Create public/sw.js:
// File: public/sw.js
self.addEventListener('push', function (event) {
if (event.data) {
const data = event.data.json()
const options = {
body: data.body,
icon: '/icon-192x192.png',
badge: '/icon-192x192.png',
tag: 'app-notification',
vibrate: [100, 50, 100],
requireInteraction: data.persistent || false,
data: {
dateOfArrival: Date.now(),
url: data.url || '/',
},
actions: [
{
action: 'open',
title: 'Open App',
},
{
action: 'close',
title: 'Dismiss',
},
],
}
event.waitUntil(
self.registration.showNotification(data.title || 'Notification', options)
)
}
})
self.addEventListener('notificationclick', function (event) {
event.notification.close()
if (event.action === 'close') {
return
}
const baseUrl = 'https://www.yourdomain.com'
const urlToOpen = event.notification.data.url
? baseUrl + event.notification.data.url
: baseUrl + '/admin'
event.waitUntil(
clients.matchAll({
type: 'window',
includeUncontrolled: true,
}).then(function (windowClients) {
// Check if app is already open
for (let i = 0; i < windowClients.length; i++) {
const client = windowClients[i]
if (client.url.includes('yourdomain.com') && 'focus' in client) {
return client.focus()
}
}
// If not open, open the app
if (clients.openWindow) {
return clients.openWindow(urlToOpen)
}
})
)
})
self.addEventListener('notificationclose', function (event) {
console.log('Notification dismissed:', event)
})
Here's what each part does. The push event listener receives incoming push messages from your server. It extracts the notification data and calls showNotification with styling options. The requireInteraction: true property (when persistent is set) means the notification stays on screen until the user explicitly dismisses it—perfect for business-critical alerts.
When a user clicks the notification, the notificationclick handler fires. It tries to focus an existing app window if one is already open. If not, it opens a new window. This prevents duplicate windows and creates a seamless user experience.
Step 5: Create Server Actions for Push Notifications
Your server needs to manage subscriptions and send notifications. Create a Server Action file at src/app/push-actions.ts:
// File: src/app/push-actions.ts
'use server'
import webpush from 'web-push'
// Configure VAPID details
webpush.setVapidDetails(
'mailto:noreply@yourdomain.com',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
interface WebPushSubscription {
endpoint: string
keys: {
p256dh: string
auth: string
}
}
// In-memory storage (use a database in production)
let subscriptions: Set<WebPushSubscription> = new Set()
export async function subscribeToPush(sub: Record<string, unknown>) {
try {
const subscription: WebPushSubscription = {
endpoint: sub.endpoint as string,
keys: {
p256dh: (sub.keys as Record<string, string>).p256dh,
auth: (sub.keys as Record<string, string>).auth,
},
}
subscriptions.add(subscription)
console.log('User subscribed to push notifications')
return { success: true, message: 'Subscribed to notifications' }
} catch (error) {
console.error('Error subscribing to push:', error)
return { success: false, message: 'Failed to subscribe' }
}
}
export async function unsubscribeFromPush(sub: Record<string, unknown>) {
try {
const endpoint = sub.endpoint as string
subscriptions.forEach((subscription) => {
if (subscription.endpoint === endpoint) {
subscriptions.delete(subscription)
}
})
console.log('User unsubscribed from push notifications')
return { success: true, message: 'Unsubscribed from notifications' }
} catch (error) {
console.error('Error unsubscribing from push:', error)
return { success: false, message: 'Failed to unsubscribe' }
}
}
export async function sendNotificationToAll(
notificationData: {
title: string
body: string
url?: string
persistent?: boolean
}
) {
try {
if (subscriptions.size === 0) {
return { success: false, message: 'No active subscriptions' }
}
const notification = {
title: notificationData.title,
body: notificationData.body,
icon: '/icon-192x192.png',
url: notificationData.url || '/admin',
persistent: notificationData.persistent || false,
}
const promises = Array.from(subscriptions).map((subscription) =>
webpush
.sendNotification(subscription, JSON.stringify(notification))
.catch((error) => {
console.error('Error sending notification:', error)
subscriptions.delete(subscription)
})
)
await Promise.all(promises)
console.log(`Notification sent to ${subscriptions.size} users`)
return { success: true, message: 'Notification sent' }
} catch (error) {
console.error('Error sending notification:', error)
return { success: false, message: 'Failed to send notification' }
}
}
This file serves two purposes. First, it manages subscriptions—storing which users have opted in to notifications. In a real application, you'd store these in a database (PostgreSQL, MongoDB, etc.). Second, it provides a function to send notifications to all subscribed users. The webpush.sendNotification function handles the actual transmission to the push service.
Step 6: Create a Push Notification Hook
Your client-side components need to subscribe to notifications. Create a hook at src/lib/hooks/use-push-notifications.ts:
// File: src/lib/hooks/use-push-notifications.ts
'use client'
import { useCallback, useEffect, useState } from 'react'
import {
subscribeToPush,
unsubscribeFromPush,
sendNotificationToAll,
} from '@/app/push-actions'
export function usePushNotifications() {
const [isSupported, setIsSupported] = useState(false)
const [isSubscribed, setIsSubscribed] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [subscription, setSubscription] = useState<PushSubscription | null>(null)
// Check if push notifications are supported
useEffect(() => {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true)
registerServiceWorker()
}
}, [])
const registerServiceWorker = useCallback(async () => {
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
updateViaCache: 'none',
})
const sub = await registration.pushManager.getSubscription()
if (sub) {
setSubscription(sub)
setIsSubscribed(true)
}
} catch (err) {
console.error('Error registering service worker:', err)
setError('Failed to register service worker')
}
}, [])
const subscribe = useCallback(async () => {
try {
setIsLoading(true)
setError(null)
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
throw new Error('Notification permission denied')
}
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
),
})
const plainSub = JSON.parse(JSON.stringify(sub))
const result = await subscribeToPush(plainSub)
if (result.success) {
setSubscription(sub)
setIsSubscribed(true)
} else {
throw new Error(result.message)
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
setError(message)
console.error('Error subscribing:', err)
} finally {
setIsLoading(false)
}
}, [])
const unsubscribe = useCallback(async () => {
try {
setIsLoading(true)
if (subscription) {
const plainSub = JSON.parse(JSON.stringify(subscription))
await unsubscribeFromPush(plainSub)
await subscription.unsubscribe()
setSubscription(null)
setIsSubscribed(false)
}
} catch (err) {
console.error('Error unsubscribing:', err)
} finally {
setIsLoading(false)
}
}, [subscription])
return {
isSupported,
isSubscribed,
isLoading,
error,
subscribe,
unsubscribe,
}
}
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
}
This hook handles the entire subscription lifecycle. When the component mounts, it attempts to register the Service Worker. The subscribe function requests the user's permission and creates a push subscription. The hook returns the current state so your components can react to subscription changes.
Step 7: Integrate Notifications Into Your App
Now connect the notifications to your actual business logic. Here's an example from a time tracking component:
// File: src/app/(admin)/time-tracking/timer-component.tsx
'use client'
import { useState } from 'react'
import { usePushNotifications } from '@/lib/hooks/use-push-notifications'
import { sendNotificationToAll } from '@/app/push-actions'
export function TimerComponent() {
const pushNotifications = usePushNotifications()
const [isRunning, setIsRunning] = useState(false)
const handleStartTimer = async () => {
setIsRunning(true)
// Subscribe if not already subscribed
if (pushNotifications.isSupported && !pushNotifications.isSubscribed) {
await pushNotifications.subscribe()
}
// Send notification to all users
if (pushNotifications.isSubscribed) {
await sendNotificationToAll({
title: 'Time Tracking Started',
body: 'Your shift has started. Tap to view details.',
url: '/admin/time-tracking',
persistent: true,
})
}
}
const handleStopTimer = async () => {
setIsRunning(false)
// Send stop notification
if (pushNotifications.isSubscribed) {
await sendNotificationToAll({
title: 'Shift Complete',
body: 'Your shift has ended. View your summary.',
url: '/admin/time-tracking',
persistent: false,
})
}
}
return (
<div>
<button
onClick={handleStartTimer}
disabled={isRunning}
>
Start Tracking
</button>
<button
onClick={handleStopTimer}
disabled={!isRunning}
>
Stop Tracking
</button>
</div>
)
}
The key insight here is that you control when notifications are sent. In a business app, you'd typically trigger notifications from your backend when significant events occur—a new delivery assignment arrives, a shift is about to end, an order is ready for pickup. The persistent: true flag ensures critical notifications stay on screen until the user acknowledges them.
Step 8: Add Security Headers
Your Service Worker should never be cached, otherwise users won't get critical updates. Add security headers to your next.config.ts:
// File: next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
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'",
},
],
},
]
},
}
export default nextConfig
The critical header is Cache-Control: no-cache, no-store, must-revalidate on the Service Worker file. This forces browsers to always fetch the latest version, ensuring your users get bug fixes and new features immediately.
Testing Your PWA
To test locally, you need HTTPS because browsers only allow Service Workers and push notifications over secure connections:
npm run dev -- --experimental-https
This generates a self-signed certificate for local development. Your app will be available at https://localhost:3000 (or your configured port).
Visit your app, and you should see a notification permission prompt. Accept it, and you can now test sending notifications. Install the app to your home screen to verify the standalone experience.
Deploying to Production
When you deploy to production, ensure:
- Your domain has a valid SSL certificate (most hosting providers handle this automatically)
- Environment variables are set in your deployment platform (Vercel, your own server, etc.)
- Your manifest file is properly linked (Next.js does this automatically)
- You have a strategy for storing subscriptions—use a database instead of in-memory storage
What You've Accomplished
You've successfully converted a Next.js 16 application into a production-ready PWA. Your employees can now install the app on their home screens like a native app, receive persistent push notifications for important business events, and experience your application without the friction of a web browser.
The beauty of this approach is that you maintain a single codebase. Changes you make on the web automatically appear in the "app" on users' home screens. You bypass app store review processes entirely. And your employees get an experience that's genuinely competitive with native apps.
In our time tracking application, we saw significant improvements: employees were more engaged with shift notifications, response times to urgent delivery changes improved because notifications actually reached them, and support tickets related to "I missed an update" dropped dramatically.
Let me know in the comments if you have questions about any part of the implementation, and subscribe for more practical development guides.
Aside: Quickly Generate Icons with ImageMagick on Mac
If you need to generate the icon files for your PWA, you can easily create them using ImageMagick via Homebrew. Here's the full command sequence to generate all three required icons from a source image:
# Install ImageMagick if you don't have it yet
brew install imagemagick
# Navigate to your project directory
cd /path/to/your/project/public
# Generate the standard icons
magick logo.png -resize 192x192 icon-192x192.png
magick logo.png -resize 512x512 icon-512x512.png
# Generate the maskable icon (centered with padding)
magick logo.png -resize 192x192 -background none -gravity center -extent 192x192 icon-192x192-maskable.png
# Generate the screenshot example
magick logo.png -resize 540x720 screenshot-1.png
The brew install imagemagick command installs ImageMagick, the most versatile CLI tool for image manipulation. The -resize flag scales your image to the specified dimensions. For maskable icons, the -background none -gravity center -extent flags ensure the image is centered and properly padded—this is important for devices that use adaptive icons on Android.
All commands preserve transparency and output to PNG by default. Replace logo.png with whatever your source image is named. If you're starting from scratch, you can also export your logo from Figma or another design tool at a high resolution (at least 512x512) and then run these commands.
Thanks, Matija