Seamless flow between Node.js API and React web + React Native (Android & iOS). Covers architecture, setup, server code, client code (web + mobile), token lifecycle, testing, best practices, and troubleshooting. Ready for production.
- Client registers for push and sends device token to your API (
/devices/register). - API stores token (userId, platform, providerId) and optionally subscribes token to topics/segments.
- When you want to notify, your API enqueues or sends to push provider (FCM or OneSignal).
- Provider delivers to devices; client handles foreground/background messages and deep links.
We’ll cover two backend options:
- FCM (firebase-admin) — full control, common choice.
- OneSignal — managed provider, easier dashboard & segmentation (OneSignal still uses FCM for Android under the hood).
- Use FCM if you want full control, single provider, no SaaS dependency.
- Use OneSignal if you want a dashboard, campaigns, analytics, and faster setup.
- You can support both (OneSignal for marketing, FCM for system messages).
This guide includes both server examples.
PORT=8000
MONGO_URI=mongodb://localhost:27017/notifications
REDIS_URL=redis://localhost:6379
API_TOKEN=supersecretapikey
# Firebase (service account JSON string or path)
FCM_CREDENTIALS_JSON='{"type":"service_account",... }'
# OneSignal
ONESIGNAL_APP_ID=your-onesignal-app-id
ONESIGNAL_API_KEY=your-onesignal-rest-api-key
Store tokens and metadata so you can target devices:
// src/models/Device.js (mongoose)
const mongoose = require('mongoose');
const DeviceSchema = new mongoose.Schema({
userId: { type: String, index: true },
provider: { type: String, enum: ['fcm', 'onesignal'], required: true },
token: { type: String, required: true, index: true },
platform: { type: String, enum: ['web', 'android', 'ios'], required: true },
createdAt: { type: Date, default: Date.now },
meta: { type: Object }
});
DeviceSchema.index({ userId: 1, provider: 1 });
module.exports = mongoose.model('Device', DeviceSchema);POST /api/v1/devices/register -> register device token
POST /api/v1/devices/unregister -> remove token
POST /api/v1/notifications/send -> send a notification (single/bulk)
POST /api/v1/notifications/preview -> preview rendering
Implement auth (API_TOKEN / JWT) for these endpoints.
npm install firebase-admin axios// src/services/fcm.provider.js
const admin = require('firebase-admin');
const logger = require('../utils/logger');
function initFirebase() {
if (!admin.apps.length) {
const creds = JSON.parse(process.env.FCM_CREDENTIALS_JSON);
admin.initializeApp({ credential: admin.credential.cert(creds) });
logger.info('[FCM] Initialized');
}
}
async function sendToDevice(deviceToken, title, body, data = {}) {
initFirebase();
const message = {
token: deviceToken,
notification: { title, body },
data: Object.fromEntries(Object.entries(data).map(([k,v])=>[k, String(v)]))
};
const res = await admin.messaging().send(message);
return res; // messageId
}
async function sendToMultiple(tokens, title, body, data = {}) {
initFirebase();
const message = {
tokens,
notification: { title, body },
data: Object.fromEntries(Object.entries(data).map(([k,v])=>[k, String(v)]))
};
const res = await admin.messaging().sendMulticast(message);
return res; // { successCount, failureCount, responses }
}
module.exports = { sendToDevice, sendToMultiple };npm install axios// src/services/onesignal.provider.js
const axios = require('axios');
const logger = require('../utils/logger');
const ONESIGNAL_API = 'https://onesignal.com/api/v1/notifications';
async function sendToPlayerIds(playerIds, title, body, data = {}) {
const payload = {
app_id: process.env.ONESIGNAL_APP_ID,
include_player_ids: Array.isArray(playerIds) ? playerIds : [playerIds],
headings: { en: title },
contents: { en: body },
data
};
const res = await axios.post(ONESIGNAL_API, payload, {
headers: { Authorization: `Basic ${process.env.ONESIGNAL_API_KEY}` }
});
return res.data;
}
module.exports = { sendToPlayerIds };OneSignal identifies devices by player_id (their SDK returns it). For web and mobile, you’ll register these IDs to your API.
Request body examples (web, RN):
Web (FCM token):
{
"userId":"user_123",
"provider":"fcm",
"platform":"web",
"token":"fcm_device_token_from_client",
"meta": { "browser":"chrome" }
}React Native (FCM):
{
"userId":"user_123",
"provider":"fcm",
"platform":"android",
"token":"fcm_device_token",
"meta": { "appVersion":"1.2.3" }
}OneSignal (player id):
{
"userId":"user_123",
"provider":"onesignal",
"platform":"ios",
"token":"onesignal_player_id",
"meta": {}
}Server handler (simplified):
// src/controllers/device.controller.js
const Device = require('../models/Device');
exports.register = async (req, res) => {
const { userId, provider, platform, token, meta } = req.body;
if (!userId || !provider || !token) return res.status(400).send({ error: 'invalid' });
// upsert so we avoid duplicates
await Device.findOneAndUpdate(
{ token },
{ userId, provider, platform, token, meta, updatedAt: new Date() },
{ upsert: true, new: true }
);
res.json({ success: true });
};Also implement unregister (delete by token).
npm install firebasepublic/firebase-messaging-sw.js (service worker) — required to receive background messages:
importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-messaging-compat.js');
firebase.initializeApp({
apiKey: 'XXX',
authDomain: 'xxx',
projectId: 'xxx',
messagingSenderId: 'xxx',
appId: 'xxx'
});
const messaging = firebase.messaging();
messaging.onBackgroundMessage(function(payload) {
// Customize notification here
const notificationTitle = payload.notification.title;
const notificationOptions = { body: payload.notification.body, data: payload.data };
self.registration.showNotification(notificationTitle, notificationOptions);
});Make sure this file is served at
/firebase-messaging-sw.js.
// src/push/firebaseWeb.js
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, onMessage } from 'firebase/messaging';
const firebaseConfig = { /* from console */ };
const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);
export async function requestPermissionAndRegister(userId) {
try {
const permission = await Notification.requestPermission();
if (permission !== 'granted') throw new Error('permission denied');
const vapidKey = 'YOUR_WEB_VAPID_KEY'; // set in Firebase console
const token = await getToken(messaging, { vapidKey });
// send token to your API
await fetch('/api/v1/devices/register', {
method: 'POST',
headers: { 'Content-Type':'application/json','Authorization':'Bearer ...' },
body: JSON.stringify({ userId, provider:'fcm', platform:'web', token })
});
return token;
} catch (err) {
console.error('push register failed', err);
}
}
// foreground handler
export function initForegroundHandler(onMessageCallback) {
onMessage(messaging, (payload) => {
onMessageCallback(payload);
});
}- Use VAPID key configured in Firebase console.
- Service worker must be at root or same scope as page.
- Browsers show push only if user granted permission.
yarn add @react-native-firebase/app @react-native-firebase/messagingFollow native setup docs (Android: add google-services.json, iOS: add GoogleService-Info.plist and enable push capabilities).
import messaging from '@react-native-firebase/messaging';
export async function registerForPush(userId) {
const authStatus = await messaging().requestPermission();
const enabled = authStatus === messaging.AuthorizationStatus.AUTHORIZED || authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (!enabled) throw new Error('no-permission');
const token = await messaging().getToken();
// send to your API
await fetch('/api/v1/devices/register', {
method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer ...'},
body: JSON.stringify({ userId, provider:'fcm', platform: Platform.OS, token })
});
return token;
}
// foreground event
messaging().onMessage(async remoteMessage => {
// show in-app UI or local notification
});
// background & quit handling: use Headless JS or native handlers (see docs)- For iOS you must set up APNs or use Firebase to set up APNs credentials in Firebase console (for FCM to forward to APNs).
- For local display in foreground, use
react-native-push-notificationornotifeeto display local notifications, because iOS doesn’t show notifications while app is in foreground automatically.
- Add OneSignal SDK to your web page and initialize with
appId. It handles subscription and returns player_id. - After initialization, call
OneSignal.getUserId()to getplayer_idand register it to your API.
yarn add react-native-onesignalFollow native setup docs. After SDK init:
import OneSignal from 'react-native-onesignal';
OneSignal.setAppId('ONESIGNAL_APP_ID');
OneSignal.setNotificationOpenedHandler((openedEvent) => {
// handle open
});
OneSignal.getDeviceState().then(state => {
const playerId = state.userId;
// register to your API: provider: 'onesignal', token: playerId
});OneSignal SDK handles permission prompts and token refresh.
// src/controllers/notify.controller.js
const Device = require('../models/Device');
const fcm = require('../services/fcm.provider');
const onesignal = require('../services/onesignal.provider');
exports.send = async (req, res) => {
const { userId, title, body, data } = req.body;
// find devices for user
const devices = await Device.find({ userId });
// group by provider
const fcmTokens = devices.filter(d => d.provider === 'fcm').map(d => d.token);
const onesignalIds = devices.filter(d => d.provider === 'onesignal').map(d => d.token);
const results = {};
if (fcmTokens.length) {
// FCM allows multicast
const resp = await fcm.sendToMultiple(fcmTokens, title, body, data);
results.fcm = resp;
}
if (onesignalIds.length) {
const resp = await onesignal.sendToPlayerIds(onesignalIds, title, body, data);
results.onesignal = resp;
}
res.json({ success:true, results });
};Important: In production you must not block on external provider calls — enqueue jobs (BullMQ/SQS) and process with workers.
- Background — Service worker
onBackgroundMessagetriggers and you show notifications viaself.registration.showNotification. - Foreground —
onMessagecallback triggers; show in-app toast or callnew Notification(...).
- Foreground —
messaging().onMessagefires. Use local notification library to show persistent notification. - Background / Quit —
messaging().setBackgroundMessageHandleror native handlers run and system displays notification automatically if FCM payload containsnotificationkey.
Payload differences:
- For background display by FCM, include
notificationkey. For data-only, handle in app code.
Include deep link data in payload data: { screen: 'chat', chatId: '123' }.
Clients read data when opening the notification and navigate accordingly.
- Web:
data.urlandclients.openWindow(data.url)in service worker. - RN: on notification open, call navigation
navigation.navigate(screen, params).
- FCM tokens change periodically. Clients should send tokens to
/devices/registeron every app launch (or ononTokenRefresh). - Remove stale tokens: when send fails with "NotRegistered" or "InvalidRegistration", remove from DB.
- Monitor provider responses and update Device collection accordingly.
- Firebase Console → Send test messages to tokens.
- OneSignal Dashboard → Send test notifications & view delivery logs.
- adb logcat (Android) and Xcode console (iOS) for device logs.
- Browser DevTools Application > Notifications for web.
- Register device from client (web or RN) → confirm DB entry.
- Use your
/sendendpoint to target the user. - Check provider response; if failures, inspect error codes and remove tokens if necessary.
- Protect device registration endpoints (authenticated users).
- Validate token format and prevent duplicates.
- Rate-limit sends per user and per tenant to avoid spam.
- Store provider secrets in secrets manager (not git). Use KMS/Secrets Manager or CI secrets.
- Use idempotency keys for bulk sends to prevent duplicates.
- Queue all sends; avoid synchronous provider calls on HTTP request path.
- Use a job queue (BullMQ, SQS) with scaled workers for fan-out and retries.
- Batch sends when provider supports it (FCM multicast).
- Implement exponential backoff and DLQ.
- Track metrics: sent, delivered, failed, token invalidations.
FCM data+notification payload (for background system display):
{
"notification": { "title": "New message", "body": "You have a new message" },
"data": { "screen":"inbox", "conversationId":"abc123" }
}FCM data-only payload (handled by app in foreground/background):
{ "data": { "type":"sync", "payload":"..." } }OneSignal payload (server):
{
"app_id":"ONESIGNAL_APP_ID",
"include_player_ids":["playerid1"],
"headings":{"en":"Hello"},
"contents":{"en":"World"},
"data":{"screen":"home"}
}- No notification on iOS: Check APNs setup in Firebase, request permissions, ensure token registration, check that provider uses APNs for iOS (FCM forwards to APNs).
- No background messages on web: Ensure
firebase-messaging-sw.jspresent at site root, VAPID key set, service worker scope correct. - Token not updating: Listen to
onTokenRefresh(web/ RN) and re-register. - High failure rate: Inspect provider responses, remove invalid tokens, throttle sends.
- Tokens are stored and de-duped.
- Background service worker registered and working (web).
- RN has correct native setup and notification permissions requested.
- Server uses queue for sending, not direct blocking calls.
- You have retry and DLQ for failed sends.
- Monitoring/alerting for error spikes (Sentry/Prometheus/Grafana).
if (Notification.permission === 'granted') {
new Notification(title, { body, data });
}import notifee from '@notifee/react-native';
await notifee.displayNotification({
title, body, data
});