notificationshard4-5 hours
Web Push Notifications
Implement browser push notifications with service workers and subscription management
pushnotificationsservice-workerpwa
Overview
Set up web push notifications that work even when the browser is closed. This blueprint covers VAPID key generation, service worker setup, subscription management, and sending notifications from your server.
Prerequisites
- •HTTPS enabled (required for service workers)
- •Database for storing subscriptions
- •Node.js backend for sending notifications
Steps
1. Generate VAPID Keys
npx web-push generate-vapid-keys
Save the output:
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BNx...
VAPID_PRIVATE_KEY=your_private_key
VAPID_EMAIL=mailto:your@email.com
2. Install Dependencies
npm install web-push
3. Service Worker
// public/sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {}
const options = {
body: data.body || 'New notification',
icon: data.icon || '/icon-192.png',
badge: '/badge-72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
},
actions: data.actions || [],
}
event.waitUntil(
self.registration.showNotification(data.title || 'Notification', options)
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = event.notification.data.url
event.waitUntil(
clients.matchAll({ type: 'window' }).then((clientList) => {
// Focus existing window or open new
for (const client of clientList) {
if (client.url === url && 'focus' in client) {
return client.focus()
}
}
return clients.openWindow(url)
})
)
})
4. Subscription Hook
// src/hooks/usePushNotifications.ts
"use client"
import { useState, useEffect } from 'react'
export function usePushNotifications() {
const [subscription, setSubscription] = useState<PushSubscription | null>(null)
const [permission, setPermission] = useState<NotificationPermission>('default')
const [supported, setSupported] = useState(false)
useEffect(() => {
setSupported('serviceWorker' in navigator && 'PushManager' in window)
setPermission(Notification.permission)
}, [])
const subscribe = async () => {
if (!supported) return null
const registration = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
const permission = await Notification.requestPermission()
setPermission(permission)
if (permission !== 'granted') return null
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
})
// Save to server
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub.toJSON()),
})
setSubscription(sub)
return sub
}
const unsubscribe = async () => {
if (!subscription) return
await subscription.unsubscribe()
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
})
setSubscription(null)
}
return { subscription, permission, supported, subscribe, unsubscribe }
}
5. API Routes
// src/app/api/push/subscribe/route.ts
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function POST(req: Request) {
const session = await auth()
if (!session?.user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const subscription = await req.json()
await db.pushSubscription.upsert({
where: { endpoint: subscription.endpoint },
create: {
userId: session.user.id,
endpoint: subscription.endpoint,
keys: subscription.keys,
},
update: {
keys: subscription.keys,
},
})
return Response.json({ success: true })
}
// src/lib/push.ts
import webpush from 'web-push'
import { db } from '@/lib/db'
webpush.setVapidDetails(
process.env.VAPID_EMAIL!,
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
)
export async function sendPushNotification(
userId: string,
payload: { title: string; body: string; url?: string }
) {
const subscriptions = await db.pushSubscription.findMany({
where: { userId },
})
const results = await Promise.allSettled(
subscriptions.map((sub) =>
webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: sub.keys as any,
},
JSON.stringify(payload)
)
)
)
// Clean up expired subscriptions
const expiredIndexes = results
.map((r, i) => (r.status === 'rejected' ? i : -1))
.filter((i) => i >= 0)
if (expiredIndexes.length > 0) {
await db.pushSubscription.deleteMany({
where: {
endpoint: {
in: expiredIndexes.map((i) => subscriptions[i].endpoint),
},
},
})
}
return results
}
6. Notification Toggle Component
// src/components/PushToggle.tsx
"use client"
import { usePushNotifications } from '@/hooks/usePushNotifications'
export function PushToggle() {
const { subscription, permission, supported, subscribe, unsubscribe } = usePushNotifications()
if (!supported) {
return <p>Push notifications not supported</p>
}
if (permission === 'denied') {
return <p>Notifications blocked. Enable in browser settings.</p>
}
return (
<button
onClick={subscription ? unsubscribe : subscribe}
className="px-4 py-2 rounded bg-blue-600 text-white"
>
{subscription ? 'Disable Notifications' : 'Enable Notifications'}
</button>
)
}
Database Schema
CREATE TABLE push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT UNIQUE NOT NULL,
keys JSONB NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_push_subscriptions_user ON push_subscriptions(user_id);
Testing Checklist
- • Service worker registers successfully
- • Permission prompt appears
- • Subscription saved to database
- • Notifications display when app is open
- • Notifications display when app is closed
- • Click opens correct URL
- • Unsubscribe removes subscription
Want to contribute?
This blueprint is open source. Found an issue or want to improve it? Edit on GitHub