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
| Browser | Web Push | Notes |
|---|---|---|
| Chrome | ✅ | Full support |
| Firefox | ✅ | Full support |
| Edge | ✅ | Full support |
| Safari (macOS) | ✅ | Since Ventura (2022) |
| Safari (iOS) | ✅ | Since iOS 16.4, requires PWA |
| Samsung Internet | ✅ | Full support |
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Asking permission on page load | Users deny immediately | Ask after user action or value moment |
| No fallback for unsupported browsers | JS errors | Check 'PushManager' in window first |
| Not handling expired subscriptions | 410 errors pile up | Remove on 410 response |
Missing userVisibleOnly: true | Subscription fails | Always set to true (required) |
| Not testing service worker updates | Old SW shows wrong notifications | Use skipWaiting() and clients.claim() |
| Sending too many notifications | Users unsubscribe | Limit to 2-3 per day max |
Building notifications? Compare Firebase vs OneSignal vs Pusher on APIScout — push notification platforms, pricing, and cross-platform support.