Back to Blueprints
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