Skip to main content

How to Add Push Notifications to a Web App

·APIScout Team
push notificationsweb pushfirebasetutorialpwa

How to Add Push Notifications to a Web App

Web push notifications re-engage users even when your app is closed. This guide covers the Web Push API directly, plus Firebase Cloud Messaging (FCM) and OneSignal for managed delivery. Works on Chrome, Firefox, Edge, and Safari (macOS Ventura+).

What You'll Build

  • Service worker registration
  • Permission request flow
  • Push subscription management
  • Server-side notification sending
  • FCM and OneSignal integrations

Prerequisites: HTTPS domain (required for service workers), Node.js 18+.

1. How Web Push Works

User grants permission → Browser creates subscription
→ Server stores subscription → Server sends push via push service
→ Push service delivers to browser → Service worker shows notification

Key pieces:

  • Service Worker — Background script that receives and displays push events
  • Push Subscription — Endpoint URL + keys identifying the user's browser
  • VAPID Keys — Your server identity (public/private key pair)

2. Generate VAPID Keys

npm install web-push
npx web-push generate-vapid-keys

Save the output:

NEXT_PUBLIC_VAPID_PUBLIC_KEY=BH1x...your-public-key
VAPID_PRIVATE_KEY=your-private-key
VAPID_SUBJECT=mailto:admin@yourdomain.com

3. Service Worker

// public/sw.js
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};

  const options = {
    body: data.body || 'You have a new notification',
    icon: data.icon || '/icon-192x192.png',
    badge: data.badge || '/badge-72x72.png',
    image: data.image,
    data: {
      url: data.url || '/',
    },
    actions: data.actions || [],
    tag: data.tag, // Replaces existing notification with same tag
    renotify: !!data.tag, // Vibrate again if replacing
    requireInteraction: data.requireInteraction || false,
  };

  event.waitUntil(
    self.registration.showNotification(data.title || 'Notification', options)
  );
});

// Handle notification click
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const url = event.notification.data?.url || '/';

  // Handle action buttons
  if (event.action === 'view') {
    event.waitUntil(clients.openWindow(url));
    return;
  }

  if (event.action === 'dismiss') {
    return; // Just close
  }

  // Default click — open URL
  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
      // Focus existing tab if open
      for (const client of windowClients) {
        if (client.url === url && 'focus' in client) {
          return client.focus();
        }
      }
      // Open new tab
      return clients.openWindow(url);
    })
  );
});

4. Client-Side Setup

Register Service Worker and Subscribe

// lib/push-notifications.ts
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;

export async function registerPush(): Promise<PushSubscription | null> {
  // Check support
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    console.warn('Push notifications not supported');
    return null;
  }

  // Register service worker
  const registration = await navigator.serviceWorker.register('/sw.js');
  await navigator.serviceWorker.ready;

  // Request permission
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.warn('Notification permission denied');
    return null;
  }

  // Subscribe to push
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true, // Required: must show a notification
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

  // Send subscription to server
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

export async function unregisterPush(): Promise<void> {
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.getSubscription();

  if (subscription) {
    await subscription.unsubscribe();
    await fetch('/api/push/unsubscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ endpoint: subscription.endpoint }),
    });
  }
}

// Helper: Convert VAPID key
function urlBase64ToUint8Array(base64String: string): Uint8Array {
  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;
}

Permission UI Component

// components/PushNotificationToggle.tsx
'use client';
import { useState, useEffect } from 'react';
import { registerPush, unregisterPush } from '@/lib/push-notifications';

export function PushNotificationToggle() {
  const [permission, setPermission] = useState<NotificationPermission>('default');
  const [subscribed, setSubscribed] = useState(false);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if ('Notification' in window) {
      setPermission(Notification.permission);
    }

    // Check existing subscription
    navigator.serviceWorker?.ready.then(async (reg) => {
      const sub = await reg.pushManager.getSubscription();
      setSubscribed(!!sub);
    });
  }, []);

  const handleToggle = async () => {
    setLoading(true);

    if (subscribed) {
      await unregisterPush();
      setSubscribed(false);
    } else {
      const sub = await registerPush();
      setSubscribed(!!sub);
      setPermission(Notification.permission);
    }

    setLoading(false);
  };

  if (permission === 'denied') {
    return (
      <p style={{ color: '#666' }}>
        Notifications blocked. Enable in browser settings.
      </p>
    );
  }

  return (
    <button onClick={handleToggle} disabled={loading}>
      {loading
        ? 'Setting up...'
        : subscribed
        ? '🔔 Notifications On — Click to Disable'
        : '🔕 Enable Notifications'}
    </button>
  );
}

5. Server-Side Sending

Store Subscriptions

// app/api/push/subscribe/route.ts
import { NextResponse } from 'next/server';

// In production, store in database
const subscriptions = new Map<string, PushSubscription>();

export async function POST(req: Request) {
  const subscription = await req.json();
  subscriptions.set(subscription.endpoint, subscription);
  return NextResponse.json({ success: true });
}

Send Notifications

// lib/push-sender.ts
import webpush from 'web-push';

webpush.setVapidDetails(
  process.env.VAPID_SUBJECT!,
  process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
);

export async function sendNotification(
  subscription: PushSubscription,
  payload: {
    title: string;
    body: string;
    url?: string;
    icon?: string;
    image?: string;
    tag?: string;
    actions?: { action: string; title: string }[];
  }
) {
  try {
    await webpush.sendNotification(
      subscription as any,
      JSON.stringify(payload)
    );
  } catch (error: any) {
    if (error.statusCode === 410) {
      // Subscription expired — remove it
      await removeSubscription(subscription.endpoint);
    }
    throw error;
  }
}

// Send to all subscribers
export async function broadcastNotification(payload: {
  title: string;
  body: string;
  url?: string;
}) {
  const subscriptions = await getAllSubscriptions();
  const results = await Promise.allSettled(
    subscriptions.map(sub => sendNotification(sub, payload))
  );

  const sent = results.filter(r => r.status === 'fulfilled').length;
  const failed = results.filter(r => r.status === 'rejected').length;

  return { sent, failed, total: subscriptions.length };
}

Send API Route

// app/api/push/send/route.ts
import { NextResponse } from 'next/server';
import { broadcastNotification } from '@/lib/push-sender';

export async function POST(req: Request) {
  const { title, body, url } = await req.json();

  const result = await broadcastNotification({ title, body, url });

  return NextResponse.json(result);
}

6. Firebase Cloud Messaging (Managed)

FCM handles the push service infrastructure and adds features like topics and analytics.

Setup

npm install firebase
// lib/firebase-messaging.ts
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, onMessage } from 'firebase/messaging';

const app = initializeApp({
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
});

const messaging = getMessaging(app);

export async function requestFCMToken(): Promise<string | null> {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;

  const token = await getToken(messaging, {
    vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY,
  });

  // Save token to your server
  await fetch('/api/push/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token }),
  });

  return token;
}

// Handle foreground messages
onMessage(messaging, (payload) => {
  // Show in-app notification or toast
  console.log('Foreground message:', payload);
});

FCM Service Worker

// public/firebase-messaging-sw.js
importScripts('https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js');

firebase.initializeApp({
  apiKey: 'your-api-key',
  projectId: 'your-project-id',
  messagingSenderId: 'your-sender-id',
  appId: 'your-app-id',
});

const messaging = firebase.messaging();

messaging.onBackgroundMessage((payload) => {
  const { title, body, icon } = payload.notification ?? {};
  self.registration.showNotification(title ?? 'Notification', {
    body,
    icon: icon ?? '/icon-192x192.png',
  });
});

Send via FCM (Server)

// lib/fcm-sender.ts
import admin from 'firebase-admin';

if (!admin.apps.length) {
  admin.initializeApp({
    credential: admin.credential.cert(
      JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!)
    ),
  });
}

export async function sendFCMNotification(
  token: string,
  notification: { title: string; body: string; imageUrl?: string }
) {
  return admin.messaging().send({
    token,
    notification,
    webpush: {
      fcmOptions: {
        link: 'https://yourapp.com/updates',
      },
    },
  });
}

// Send to topic (all subscribed users)
export async function sendToTopic(
  topic: string,
  notification: { title: string; body: string }
) {
  return admin.messaging().send({
    topic,
    notification,
  });
}

7. OneSignal (Fastest Setup)

<!-- Add to your HTML head -->
<script src="https://cdn.onesignal.com/sdks/web/v16/OneSignalSDK.page.js" defer></script>
<script>
  window.OneSignalDeferred = window.OneSignalDeferred || [];
  OneSignalDeferred.push(async function(OneSignal) {
    await OneSignal.init({
      appId: "YOUR-ONESIGNAL-APP-ID",
    });
  });
</script>
// React integration
import OneSignal from 'react-onesignal';

// In your app initialization
await OneSignal.init({
  appId: process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID!,
  allowLocalhostAsSecureOrigin: true,
});

// Show prompt
OneSignal.Slidedown.promptPush();

Browser Support

BrowserWeb PushNotes
ChromeFull support
FirefoxFull support
EdgeFull support
Safari (macOS)Since Ventura (2022)
Safari (iOS)Since iOS 16.4, requires PWA
Samsung InternetFull support

Common Mistakes

MistakeImpactFix
Asking permission on page loadUsers deny immediatelyAsk after user action or value moment
No fallback for unsupported browsersJS errorsCheck 'PushManager' in window first
Not handling expired subscriptions410 errors pile upRemove on 410 response
Missing userVisibleOnly: trueSubscription failsAlways set to true (required)
Not testing service worker updatesOld SW shows wrong notificationsUse skipWaiting() and clients.claim()
Sending too many notificationsUsers unsubscribeLimit to 2-3 per day max

Building notifications? Compare Firebase vs OneSignal vs Pusher on APIScout — push notification platforms, pricing, and cross-platform support.

Comments