Push Notification Service
Web and mobile push notifications using Firebase Cloud Messaging (FCM) and Web Push API. Send real-time notifications to users across platforms.
Installation & Setup
1. Install Firebase SDK
pnpm add firebase firebase-admin2. Configure Firebase
Create a Firebase project at console.firebase.google.com
# .env
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
# Public keys for web push
NEXT_PUBLIC_FIREBASE_API_KEY=AIzaSyXXXXXXXX
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project-id
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789
NEXT_PUBLIC_FIREBASE_APP_ID=1:123456789:web:xxxxx
NEXT_PUBLIC_VAPID_KEY=BNxxxxxxxxxxxxxx3. Initialize Firebase Admin
// packages/core/src/services/push/admin.ts
import * as admin from 'firebase-admin';
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
clientEmail: process.env.FIREBASE_CLIENT_EMAIL
})
});
}
export const messaging = admin.messaging();4. Initialize Firebase Client
// lib/firebase.ts
import { initializeApp } from 'firebase/app';
import { getMessaging } from 'firebase/messaging';
const firebaseConfig = {
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 app = initializeApp(firebaseConfig);
export const messaging = getMessaging(app);3. Usage
The Push Notification Service is available as a singleton via getPushNotificationService.
import { getPushNotificationService } from '@orbitusdev/core/services/push';
const pushService = getPushNotificationService();Supported Providers
- Firebase Cloud Messaging (FCM) (Default)
Client-Side Setup
Request Permission
'use client';
import { useEffect, useState } from 'react';
import { getToken, onMessage } from 'firebase/messaging';
import { messaging } from '@/lib/firebase';
export function usePushNotifications() {
const [token, setToken] = useState<string | null>(null);
const [permission, setPermission] = useState<NotificationPermission>(
typeof window !== 'undefined' ? Notification.permission : 'default'
);
useEffect(() => {
if (typeof window === 'undefined' || !('Notification' in window)) {
return;
}
// Listen for messages when app is in foreground
const unsubscribe = onMessage(messaging, (payload) => {
console.log('Message received:', payload);
// Show notification
new Notification(payload.notification?.title ?? 'New Notification', {
body: payload.notification?.body,
icon: payload.notification?.image ?? '/icon-192.png',
badge: '/badge-72.png'
});
});
return () => unsubscribe();
}, []);
const requestPermission = async () => {
try {
const permission = await Notification.requestPermission();
setPermission(permission);
if (permission === 'granted') {
const currentToken = await getToken(messaging, {
vapidKey: process.env.NEXT_PUBLIC_VAPID_KEY
});
if (currentToken) {
setToken(currentToken);
// Save token to backend
await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: currentToken })
});
}
}
} catch (error) {
console.error('Error requesting permission:', error);
}
};
return { token, permission, requestPermission };
}Notification Component
'use client';
import { usePushNotifications } from '@/hooks/use-push-notifications';
export function NotificationPrompt() {
const { permission, requestPermission } = usePushNotifications();
if (permission === 'granted') {
return null;
}
return (
<div className="notification-prompt">
<p>Enable notifications to stay updated!</p>
<button onClick={requestPermission}>
{permission === 'denied' ? 'Blocked' : 'Enable Notifications'}
</button>
</div>
);
}Service Worker
Create public/firebase-messaging-sw.js:
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
firebase.initializeApp({
apiKey: 'YOUR_API_KEY',
authDomain: 'your-project.firebaseapp.com',
projectId: 'your-project-id',
storageBucket: 'your-project.appspot.com',
messagingSenderId: '123456789',
appId: '1:123456789:web:xxxxx'
});
const messaging = firebase.messaging();
// Handle background messages
messaging.onBackgroundMessage((payload) => {
console.log('Background message received:', payload);
const notificationTitle = payload.notification?.title ?? 'New Notification';
const notificationOptions = {
body: payload.notification?.body,
icon: payload.notification?.image ?? '/icon-192.png',
badge: '/badge-72.png',
data: payload.data
};
self.registration.showNotification(notificationTitle, notificationOptions);
});
// Handle notification clicks
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url ?? '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Check if window is already open
for (const client of clientList) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});API Routes
Subscribe to Notifications
// app/api/notifications/subscribe/route.ts
import { auth } from '@orbitusdev/auth';
import { prisma } from '@orbitusdev/database';
export async function POST(req: Request) {
const session = await auth();
if (!session?.user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const { token } = await req.json();
// Save FCM token to database
await prisma.notificationToken.upsert({
where: { userId: session.user.id },
update: { token },
create: {
userId: session.user.id,
token
}
});
return Response.json({ success: true });
}Send Notification
// app/api/notifications/send/route.ts
import { getPushNotificationService } from '@orbitusdev/core/services/push';
import { prisma } from '@orbitusdev/database';
export async function POST(req: Request) {
const { userId, title, body, url } = await req.json();
// Get user's FCM token
const tokenData = await prisma.notificationToken.findUnique({
where: { userId }
});
if (!tokenData?.token) {
return Response.json({ error: 'No token found' }, { status: 404 });
}
// Send notification
const pushService = getPushNotificationService();
await pushService.sendPushNotification({
token: tokenData.token,
title,
body,
clickAction: url,
data: { url }
});
// Save to database
await prisma.notification.create({
data: {
userId,
title,
body,
url,
read: false
}
});
return Response.json({ success: true });
}Use Cases
Order Updates
import { getPushNotificationService } from '@orbitusdev/core/services/push';
export async function notifyOrderStatus(order: {
userId: string;
orderId: string;
status: string;
}) {
const token = await getUserFCMToken(order.userId);
if (!token) return;
const pushService = getPushNotificationService();
await pushService.sendPushNotification({
token,
title: 'Order Update',
body: `Your order #${order.orderId} is now ${order.status}`,
clickAction: `/orders/${order.orderId}`,
imageUrl: 'https://orbitus.dev/order-icon.png'
});
}New Message Notification
import { getPushNotificationService } from '@orbitusdev/core/services/push';
export async function notifyNewMessage(message: {
recipientId: string;
senderName: string;
content: string;
conversationId: string;
}) {
const token = await getUserFCMToken(message.recipientId);
if (!token) return;
const pushService = getPushNotificationService();
await pushService.sendPushNotification({
token,
title: `New message from ${message.senderName}`,
body: message.content.slice(0, 100),
clickAction: `/messages/${message.conversationId}`
});
}Bulk Notifications
import { getPushNotificationService } from '@orbitusdev/core/services/push';
export async function sendToAllUsers(notification: {
title: string;
body: string;
url?: string;
}) {
// Get all active tokens
const tokens = await prisma.notificationToken.findMany({
where: { active: true },
select: { token: true }
});
const tokenStrings = tokens.map((t) => t.token);
const pushService = getPushNotificationService();
// Send in batches of 500 (FCM limit)
const batchSize = 500;
for (let i = 0; i < tokenStrings.length; i += batchSize) {
const batch = tokenStrings.slice(i, i + batchSize);
await pushService.sendMulticastNotification(batch, notification);
}
}User Preferences
Notification Settings Schema
model NotificationPreferences {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id])
orders Boolean @default(true)
messages Boolean @default(true)
marketing Boolean @default(false)
updates Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Check Preferences Before Sending
import { getPushNotificationService } from '@orbitusdev/core/services/push';
export async function sendNotificationWithPreferences(params: {
userId: string;
type: 'orders' | 'messages' | 'marketing' | 'updates';
title: string;
body: string;
url?: string;
}) {
// Check user preferences
const prefs = await prisma.notificationPreferences.findUnique({
where: { userId: params.userId }
});
if (!prefs?.[params.type]) {
return; // User disabled this notification type
}
const token = await getUserFCMToken(params.userId);
if (!token) return;
const pushService = getPushNotificationService();
await pushService.sendPushNotification({
token,
title: params.title,
body: params.body,
clickAction: params.url
});
}Scheduled Notifications
// Using a cron job or scheduler
import { getPushNotificationService } from '@orbitusdev/core/services/push';
export async function sendScheduledReminders() {
const reminders = await prisma.reminder.findMany({
where: {
scheduledFor: {
lte: new Date(),
gt: new Date(Date.now() - 60000) // Last minute
},
sent: false
},
include: { user: { include: { notificationToken: true } } }
});
const pushService = getPushNotificationService();
for (const reminder of reminders) {
if (!reminder.user.notificationToken?.token) continue;
await pushService.sendPushNotification({
token: reminder.user.notificationToken.token,
title: 'Reminder',
body: reminder.message,
clickAction: reminder.url
});
await prisma.reminder.update({
where: { id: reminder.id },
data: { sent: true }
});
}
}Best Practices
- Request permission at right time - Don’t ask immediately
- Provide value - Explain why notifications are useful
- Respect user preferences - Allow granular control
- Handle token refresh - FCM tokens can expire
- Test on multiple browsers - Safari, Chrome, Firefox
- Keep messages short - Title: 40 chars, body: 120 chars
- Use rich media - Images increase engagement
- Time notifications wisely - Respect time zones
- Track deliverability - Monitor success rates
- Provide opt-out - Easy unsubscribe mechanism
Error Handling
import { getPushNotificationService } from '@orbitusdev/core/services/push';
import { logger } from '@orbitusdev/core/services/logger';
export async function sendPushSafe(params: SendPushParams) {
const pushService = getPushNotificationService();
try {
const result = await pushService.sendPushNotification(params);
logger.info('Push notification sent', { token: params.token.slice(0, 10) });
return result;
} catch (error) {
// Handle invalid token
if (error.code === 'messaging/registration-token-not-registered') {
await prisma.notificationToken.delete({ where: { token: params.token } });
logger.warn('Removed invalid FCM token', { token: params.token.slice(0, 10) });
} else {
logger.error('Failed to send push notification', error);
}
throw error;
}
}Testing
Test Notification
import { getPushNotificationService } from '@orbitusdev/core/services/push';
const testToken = 'YOUR_TEST_TOKEN';
const pushService = getPushNotificationService();
await pushService.sendPushNotification({
token: testToken,
title: 'Test Notification',
body: 'This is a test notification',
clickAction: 'https://orbitus.dev'
});Mock Service
// tests/mocks/push.ts
export const mockSendPush = jest.fn().mockResolvedValue({
success: true,
messageId: 'projects/test/messages/12345'
});
jest.mock('@orbitusdev/core/services/push', () => ({
sendPushNotification: mockSendPush
}));Related
Last updated on