PWA with Next.js App Router
Instructions
This skill covers everything needed to turn a Next.js App Router project into a fully-featured PWA with offline support (via Serwist), push notifications (via VAPID/web-push), and home screen installation.
IMPORTANT: Next.js 16 uses Turbopack by default. You need to install @serwist/turbopack and serwist to use Serwist: https://serwist.pages.dev/docs/next/turbo
1. Web App Manifest
Create app/manifest.ts (Next.js handles routing automatically):
// app/manifest.ts
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'My App',
short_name: 'App',
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' },
{ src: '/icons/icon-512x512.png', sizes: '512x512', type: 'image/png' },
{
src: '/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
}
}
Generate icon sets at https://realfavicongenerator.net/ and place them in public/icons/.
2. Offline Support with Serwist
Installation
yarn add -D @serwist/turbopack serwist
next.config.ts
For TypeScript (next.config.ts):
import { withSerwist } from '@serwist/turbopack'
import type { NextConfig } from 'next'
const securityHeaders = async () => [
// ...security headers (see section 3)
]
export default withSerwist({
swSrc: 'app/sw.ts',
swDest: 'public/sw.js',
disable: process.env.NODE_ENV === 'development', // disable during dev to avoid stale cache
reactStrictMode: true,
output: 'standalone',
headers: securityHeaders
} as any as NextConfig)
For CommonJS (next.config.js):
const { withSerwist } = require('@serwist/turbopack')
module.exports = withSerwist({
reactStrictMode: true,
output: 'standalone',
// ...your Next.js config
})
Service Worker (app/sw.ts)
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { defaultCache } from '@serwist/turbopack/worker'
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
import { Serwist } from 'serwist'
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined
}
}
declare const self: ServiceWorkerGlobalScope
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache, // optimized for Next.js routes
})
serwist.addEventListeners()
defaultCache handles static assets, API routes, RSC prefetches, and HTML pages automatically. In dev mode it uses NetworkOnly to prevent stale content.
tsconfig.json additions
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext", "webworker"]
},
"exclude": ["public/sw.js", "public/swe-worker*.js"]
}
.gitignore additions
public/sw.js
public/sw.js.map
public/swe-worker*.js
Key Serwist config options
| Option | Default | Description |
|---|
| register | true | Auto-registers the SW |
| cacheOnNavigation | false | Cache pages during frontend nav |
| reloadOnOnline | false | Reload page when connection restored |
| additionalPrecacheEntries | [] | Extra files to precache |
| globPublicPatterns | ['**/*'] | Patterns for public dir precaching |
3. Security Headers
Add to next.config.ts:
const securityHeaders = async () => [
{
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'" },
],
},
]
4. Home Screen Installation
Add this component where appropriate (e.g., in a settings page or a banner):
// components/InstallPrompt.tsx
'use client'
import { useState, useEffect } from 'react'
export function InstallPrompt() {
const [isIOS, setIsIOS] = useState(false)
const [isStandalone, setIsStandalone] = useState(false)
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
useEffect(() => {
setIsIOS(/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream)
setIsStandalone(window.matchMedia('(display-mode: standalone)').matches)
const handler = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e)
}
window.addEventListener('beforeinstallprompt', handler)
return () => window.removeEventListener('beforeinstallprompt', handler)
}, [])
if (isStandalone) return null
const handleInstall = async () => {
if (deferredPrompt) {
deferredPrompt.prompt()
await deferredPrompt.userChoice
setDeferredPrompt(null)
}
}
return (
<div>
{deferredPrompt && (
<button App</button>
)}
{isIOS && (
<p>
To install: tap the Share button ⎋ then "Add to Home Screen" ➕
</p>
)}
</div>
)
}
Note:
beforeinstallpromptis Chromium-only. iOS requires manual instructions (shown above). Don't rely on it as the only install path.
5. Push Notifications
Generate VAPID Keys
yarn global add web-push
web-push generate-vapid-keys
Add to .env.local:
NEXT_PUBLIC_VAPID_PUBLIC_KEY=your_public_key_here
VAPID_PRIVATE_KEY=your_private_key_here
VAPID_EMAIL=mailto:your-email@example.com
Install web-push
yarn add web-push
yarn add -D @types/web-push
Server Actions (app/actions.ts)
'use server'
import webpush, { PushSubscription } from 'web-push'
webpush.setVapidDetails(
process.env.VAPID_EMAIL!,
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
export async function subscribeUser(sub: PushSubscription) {
// Store in database: await db.pushSubscriptions.upsert({ data: sub, where: { endpoint: sub.endpoint } })
return { success: true }
}
export async function unsubscribeUser(endpoint: string) {
// Remove from database: await db.pushSubscriptions.delete({ where: { endpoint } })
return { success: true }
}
export async function sendNotification(endpoint: string, message: string) {
// Load subscription from database
// const sub = await db.pushSubscriptions.findUnique({ where: { endpoint } })
try {
await webpush.sendNotification(
sub,
JSON.stringify({ title: 'Notification', body: message, icon: '/icons/icon-192x192.png' })
)
return { success: true }
} catch (error) {
console.error('Push notification error:', error)
return { success: false }
}
}
Push Manager Component
// components/PushNotificationManager.tsx
'use client'
import { useState, useEffect } from 'react'
import { subscribeUser, unsubscribeUser } from '@/app/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)
return new Uint8Array([...rawData].map((c) => c.charCodeAt(0)))
}
export function PushNotificationManager() {
const [isSupported, setIsSupported] = useState(false)
const [subscription, setSubscription] = useState<PushSubscription | null>(null)
useEffect(() => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true)
navigator.serviceWorker
.register('/sw.js', { scope: '/', updateViaCache: 'none' })
.then((reg) => reg.pushManager.getSubscription())
.then(setSubscription)
}
}, [])
const subscribe = async () => {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
})
setSubscription(sub)
await subscribeUser(JSON.parse(JSON.stringify(sub)))
}
const unsubscribe = async () => {
await subscription?.unsubscribe()
await unsubscribeUser(subscription!.endpoint)
setSubscription(null)
}
if (!isSupported) return null
return (
<div>
{subscription ? (
<button Notifications</button>
) : (
<button Notifications</button>
)}
</div>
)
}
Service Worker Push Handler
Add to app/sw.ts (alongside Serwist setup):
// Push notification handling — add before serwist.addEventListeners()
self.addEventListener('push', (event: PushEvent) => {
if (!event.data) return
const data = event.data.json()
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon || '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
vibrate: [100, 50, 100],
data: { url: data.url || '/' },
})
)
})
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close()
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
const url = event.notification.data?.url || '/'
const existingClient = clientList.find((c) => c.url === url && 'focus' in c)
return existingClient ? existingClient.focus() : clients.openWindow(url)
})
)
})
6. Testing Locally
# Run with HTTPS (required for SW + Push APIs)
next dev --experimental-https
Checklist:
- Browser has notifications enabled (not blocked globally)
- Service worker appears in DevTools → Application → Service Workers
- Manifest validated in DevTools → Application → Manifest
- Lighthouse PWA audit passes
- Test on iOS (Safari) and Chrome separately — behavior differs
7. Browser Support Notes
| Feature | Chrome | Firefox | Safari iOS |
|---|---|---|---|
| Service Worker | ✅ | ✅ | ✅ 11.1+ |
| Push Notifications | ✅ | ✅ | ✅ 16.4+ (installed only) |
beforeinstallprompt |
✅ | ❌ | ❌ |
| Install to home screen | ✅ auto | manual | manual share |
Examples
Minimal setup (manifest + Serwist only, no push)
- Create
app/manifest.ts - Install
@serwist/turbopackandserwistas dev dependencies - Wrap
next.config.tswithwithSerwist(use higher-order function pattern) - Create
app/sw.tswithdefaultCache - Update
tsconfig.jsonand.gitignore
Full PWA with push notifications
Follow all 7 steps above end-to-end. Store push subscriptions in the database (Supabase) keyed by endpoint. Trigger sends from server actions or Supabase Edge Functions.