How to Add Push Notifications to a Web App 2026
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 |
The Permission Prompt UX
The permission prompt is the most fragile moment in the push notification flow. Browsers will only show the native permission dialog once — if the user dismisses it or clicks "Block," you cannot ask again without them manually changing browser settings. This makes the timing and framing of your prompt critical.
Never ask on page load. The single biggest mistake is showing the notification prompt the moment a user arrives. Users who haven't yet understood your app's value will reflexively click "Block," and you lose them permanently. Studies from push notification providers consistently show permission grant rates of 5-15% for immediate prompts versus 40-70% for contextual prompts shown at the right moment.
Ask at the value moment. The right trigger is when the user has just taken an action where notifications would clearly benefit them. A few examples: immediately after a user sets up a price alert ("We'll notify you when the price drops — enable notifications?"), after the first successful order ("Want to get shipping updates?"), or after the user has visited the app three or more times. These contextual moments produce dramatically higher acceptance rates.
Implement a custom pre-prompt UI before triggering the browser native dialog. Show your own UI first — a modal or banner explaining what you'll notify them about — and only call Notification.requestPermission() when the user clicks "Yes, enable notifications." This way, if they click "No" on your UI, you can ask again later. If they click "No" on the browser dialog, you cannot.
Handle the denied state gracefully. When Notification.permission === 'denied', do not show repeated prompts or error messages. Instead, show a one-time message explaining how to re-enable in browser settings, then never show it again. Store the "denied" state in your database so you don't ask users who have already declined.
iOS Safari PWA requirements changed in iOS 16.4: web push now works, but only for apps installed as PWAs (added to home screen). Users on iOS Safari who haven't installed the PWA will never receive web push notifications, regardless of permission. Detect this case and prompt iOS users to "Add to Home Screen" before showing the notification permission flow:
function isIOSSafariWithoutPWA(): boolean {
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
return isIOS && !isStandalone;
}
Testing Push Notifications in Development
Testing web push requires HTTPS, which complicates local development. Chrome and Firefox both allow localhost to act as a secure origin for service worker registration — http://localhost:3000 works as-is for the VAPID subscription flow. Safari does not grant localhost the same exception and requires HTTPS even locally, so use ngrok or Cloudflare Tunnel if you need to test on Safari.
Chrome DevTools provides the best debugging experience. In the Application panel, the "Service Workers" tab shows registration status, lets you trigger push events manually (click "Push" with a JSON payload), and allows you to inspect cached assets. The Console tab shows service worker logs — console.log calls inside sw.js appear there, not in the main page console. This trips up many developers who don't realize their service worker is running but logging to the wrong console.
Testing the full flow locally:
# 1. Start your dev server
npm run dev
# 2. Register service worker and subscribe (works on localhost)
# 3. Use the Notifications test tool in Chrome DevTools Application panel
# Or send a test push from the command line using web-push:
node -e "
const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:test@test.com',
'YOUR_PUBLIC_VAPID_KEY',
'YOUR_PRIVATE_VAPID_KEY'
);
const subscription = JSON.parse(process.env.TEST_SUBSCRIPTION);
webpush.sendNotification(subscription, JSON.stringify({
title: 'Test Notification',
body: 'This is a test push!',
url: 'http://localhost:3000'
}));
"
Simulating push events in Playwright E2E tests requires granting the notification permission in the browser context:
// playwright.config.ts
const context = await browser.newContext({
permissions: ['notifications'],
});
Service worker update lifecycle also needs testing. When you deploy a new version of sw.js, existing users have the old version running. The new version is installed but stays in "waiting" state until all tabs using the old version are closed. Add skipWaiting() and clients.claim() to your service worker to immediately activate updates, or implement a banner prompting the user to refresh when a new version is available.
Managing Push Subscriptions at Scale
Push subscriptions expire silently. The endpoint URL in a PushSubscription object is valid until the user clears browser data, reinstalls the browser, or the push service rotates the endpoint. When a subscription expires, sending to it returns a 410 Gone HTTP status code (or 404 from some push services). If you don't clean these up, your subscription database fills with invalid entries.
Cleanup strategy: after every batch send, audit the results and remove subscriptions that returned 410 or 404. For direct VAPID sends, the web-push library throws an error with statusCode === 410. For FCM, it returns messaging/registration-token-not-registered. Handle both:
// web-push (VAPID direct):
try {
await webpush.sendNotification(sub, payload);
} catch (err: any) {
if (err.statusCode === 410 || err.statusCode === 404) {
await db.pushSubscriptions.delete({ where: { endpoint: sub.endpoint } });
}
}
Subscription refresh is a good defensive measure. When a user opens your app, check if their subscription is still valid by calling registration.pushManager.getSubscription(). If it returns null (subscription was lost), silently re-subscribe in the background if you have a stored preference that they opted in. This handles the case where users clear browser data or the subscription naturally expires:
async function ensureSubscription(): Promise<void> {
const reg = await navigator.serviceWorker.ready;
const existing = await reg.pushManager.getSubscription();
if (!existing) {
// User previously opted in but subscription was lost:
const userPreference = await getUserPushPreference();
if (userPreference === 'enabled') {
const newSub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
});
await savePushSubscription(newSub);
}
}
}
Daily subscription count monitoring: track total_subscriptions vs active_subscriptions (those that received a successful notification in the last 30 days). A growing gap between the two indicates subscription churn — either users are unsubscribing or subscriptions are expiring without cleanup. Aim to keep dead subscriptions below 10% of your total to avoid artificially inflated subscriber counts.
Methodology
Push notifications are a double-edged tool: used thoughtfully, they drive meaningful re-engagement. Used carelessly, they train users to immediately swipe-dismiss anything from your app, or to revoke permission entirely. The data on optimal frequency consistently shows that 2-3 notifications per week is the ceiling for most consumer apps before unsubscribe rates climb. Transactional notifications (order updates, security alerts) have much higher tolerance because they're tied to events the user triggered — but marketing pushes should be sparse and directly valuable.
Web Push API specification follows the W3C Push API standard and RFC 8030 (Generic Event Delivery Using HTTP Push). Service worker lifecycle behavior described per the W3C Service Workers specification; actual behavior varies slightly between browsers — Chrome, Firefox, and Safari handle skipWaiting and clients.claim similarly but have differences in update timing. VAPID key generation uses Elliptic Curve Diffie-Hellman (ECDH) on the P-256 curve per RFC 8292. Browser push service endpoints: Chrome uses Google's Firebase Cloud Messaging push service, Firefox uses Mozilla's Autopush, Safari uses Apple Push Notification service — all are compatible with the W3C Web Push API from the developer's perspective. iOS Safari web push requires iOS 16.4+ and the app must be installed as a PWA per Apple's documentation as of March 2026. FCM code examples use Firebase Admin SDK 12.x. web-push npm package is version 3.x. OneSignal SDK examples use react-onesignal v2.x and the OneSignal v16 web SDK. Notification permission state (default, granted, denied) is stored in the browser and persists across sessions — applications cannot reset this state programmatically, only the user can change it through browser settings. FCM endpoints and VAPID endpoints are not interchangeable: FCM tokens work with the Firebase Admin SDK's messaging.send() method, while VAPID subscriptions work with web-push.sendNotification(). Choose one approach per deployment and document which you're using.
Building notifications? Compare Firebase vs OneSignal vs Pusher on APIScout — push notification platforms, pricing, and cross-platform support.
Related: Best In-App Notification APIs 2026, Best Push Notification APIs in 2026, Building an AI-Powered App: Choosing Your API Stack