A complete, production-grade guide covering topic-based and specific notification delivery — from Firebase project setup to fully working Android & iOS integration.
Stack: React Native CLI · NestJS · Firebase FCM v1 · @react-native-firebase · @notifee/react-native · firebase-admin SDK
Read time: ~25 minutes
Updated: 2025
Table of Contents
- Architecture Overview
- Firebase Project Setup
- React Native CLI Installation
- Android Configuration
- iOS Configuration
- Token Management
- NestJS Backend Service
- Topic-Based Notifications
- Specific User Notifications
- Handling Incoming Messages
- Rich Notifications with Notifee
- Testing & Debugging
1. Architecture Overview
Before writing a single line of code, it's critical to understand the full notification flow. Firebase Cloud Messaging (FCM) acts as the backbone — a reliable, scalable delivery infrastructure managed by Google.
┌─────────────────┐ registers token ┌──────────────────┐
│ React Native │ ─────────────────────────► │ Firebase FCM │
│ Mobile App │ │ Token Registry │
└─────────────────┘ └──────────────────┘
│
│ stores token
▼
┌─────────────────┐ sends message ┌──────────────────┐
│ Notification │ ◄────────────────────────── │ NestJS Backend │
│ Delivered 🔔 │ │ firebase-admin │
└─────────────────┘ └──────────────────┘
Notification Types
| Type | Mechanism | Best For |
|---|---|---|
| Specific | Single FCM device token | Personal alerts, DMs, account events |
| Topic-based | Topic subscription | News, sports scores, broadcasts |
| Multicast | Array of tokens (max 500) | Group notifications, cohorts |
| Condition | Topic boolean logic | Segmented campaigns |
2. Firebase Project Setup
Head to the Firebase Console and create a new project. We'll configure it for both Android and iOS.
Steps
Step 1 — Create Firebase Project
Go to console.firebase.google.com → Add project → Enable Google Analytics (optional) → Create project.
Step 2 — Register Android App
Click the Android icon → Enter your package name (e.g. com.myapp) → Download google-services.json → Place it in android/app/.
Step 3 — Register iOS App
Click the iOS icon → Enter your Bundle ID (e.g. com.myapp) → Download GoogleService-Info.plist → Add it to your Xcode project root.
Step 4 — Generate Service Account Key
Go to Project Settings → Service Accounts → Generate new private key. This JSON file is used by your NestJS backend.
⚠️ Security: Add your service account JSON to
.gitignoreimmediately. Use environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault) in production. Never commit it to version control.
3. React Native CLI Installation
We use @react-native-firebase — the de-facto community library that wraps the native Firebase SDKs. This gives you real push notification support backed by native code, not a JavaScript-only polyfill.
# Install core Firebase app module (required)
npm install @react-native-firebase/app
# Install FCM messaging module
npm install @react-native-firebase/messaging
# Install Notifee for rich local notification display
npm install @notifee/react-native
# iOS only — install pods
cd ios && pod install && cd ..
Note:
@react-native-firebase/messaginghandles FCM token registration, topic subscription, and background message receipt.@notifee/react-nativehandles the actual display of notifications with custom channels, sounds, and actions — especially critical on Android 13+.
4. Android Configuration
Project-level build.gradle
// android/build.gradle
buildscript {
dependencies {
// Add Google Services plugin
classpath('com.google.gms:google-services:4.4.2')
}
}
App-level build.gradle
// android/app/build.gradle
apply plugin: 'com.android.application'
apply plugin: 'com.google.gms.google-services' // ← add this
android {
defaultConfig {
minSdkVersion 21 // required by Firebase
targetSdkVersion 34
compileSdkVersion 34
}
}
dependencies {
// Firebase BoM — manages all Firebase library versions
implementation platform('com.google.firebase:firebase-bom:33.0.0')
implementation 'com.google.firebase:firebase-messaging'
implementation 'com.google.firebase:firebase-analytics'
}
AndroidManifest.xml — Permissions & Metadata
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest>
<!-- Required for Android 13+ (API 33+) -->
<uses-permission
android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- FCM default notification channel -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="default_channel" />
<!-- Custom notification icon (small icon — must be white monochrome) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />
</application>
</manifest>
5. iOS Configuration
Enable Push Notifications Capability
In Xcode: Select your target → Signing & Capabilities → Click + Capability → Add:
- Push Notifications
- Background Modes → check Remote notifications
AppDelegate.mm
// ios/MyApp/AppDelegate.mm
#import <Firebase.h>
#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Configure Firebase — must be called before any other Firebase usage
[FIRApp configure];
self.moduleName = @"MyApp";
self.initialProps = @{};
return [super application:application
didFinishLaunchingWithOptions:launchOptions];
}
@end
APNs Authentication Key
In Firebase Console → Project Settings → Cloud Messaging → Apple app configuration → upload your APNs Authentication Key (.p8 file) from Apple Developer Portal.
⚡ iOS Simulator: Push notifications do NOT work on the iOS Simulator for real APNs tokens. You must test on a physical device.
6. Token Management
Every device gets a unique FCM registration token. This token is what your server uses to target a specific device. Tokens can rotate — so you must handle refresh events and sync them to your backend reliably.
// src/hooks/useFCMToken.ts
import { useEffect, useCallback } from 'react';
import messaging from '@react-native-firebase/messaging';
import { Platform, Alert } from 'react-native';
import { api } from '../services/api';
export function useFCMToken() {
/**
* Request notification permission (required on iOS + Android 13+)
*/
const requestPermission = useCallback(async () => {
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (!enabled) {
Alert.alert(
'Notifications Disabled',
'Enable notifications in Settings to receive alerts.',
);
}
return enabled;
}, []);
/**
* Get the current FCM token and register with backend
*/
const registerToken = useCallback(async () => {
try {
// iOS: must check APNs token first
if (Platform.OS === 'ios') {
const apnsToken = await messaging().getAPNSToken();
if (!apnsToken) return; // APNs not ready yet
}
const fcmToken = await messaging().getToken();
if (fcmToken) {
console.log('FCM Token:', fcmToken);
// Send token to your NestJS backend
await api.post('/devices/token', {
token: fcmToken,
platform: Platform.OS,
});
}
} catch (error) {
console.error('Token registration failed:', error);
}
}, []);
useEffect(() => {
const init = async () => {
const permitted = await requestPermission();
if (permitted) await registerToken();
};
init();
// Listen for token refresh — tokens can change!
const unsubscribe = messaging().onTokenRefresh(async (newToken) => {
console.log('Token refreshed:', newToken);
await api.post('/devices/token', {
token: newToken,
platform: Platform.OS,
});
});
return unsubscribe; // cleanup on unmount
}, [requestPermission, registerToken]);
}
✅ Best Practice: Store the token in your database alongside the user ID, device platform, app version, and a timestamp. When sending, handle
messaging/registration-token-not-registerederrors to auto-delete stale tokens.
7. NestJS Backend Service
Installation
# In your NestJS project
npm install firebase-admin
# Generate NestJS module, service, and controller
nest generate module notifications
nest generate service notifications
nest generate controller notifications
Firebase Admin Module
// src/firebase/firebase.module.ts
import { Module, Global } from '@nestjs/common';
import * as admin from 'firebase-admin';
import { ConfigService } from '@nestjs/config';
@Global()
@Module({
providers: [
{
provide: 'FIREBASE_ADMIN',
useFactory: (configService: ConfigService) => {
if (admin.apps.length > 0) {
return admin.app();
}
return admin.initializeApp({
credential: admin.credential.cert({
projectId: configService.get('FIREBASE_PROJECT_ID'),
clientEmail: configService.get('FIREBASE_CLIENT_EMAIL'),
privateKey: configService
.get<string>('FIREBASE_PRIVATE_KEY')
.replace(/\\n/g, '\n'),
}),
});
},
inject: [ConfigService],
},
],
exports: ['FIREBASE_ADMIN'],
})
export class FirebaseModule {}
Environment Variables
# .env
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxx@your-project.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----\n"
8. Topic-Based Notifications
Topics allow you to send one message that reaches all users subscribed to that topic — perfect for news categories, sports teams, or feature announcements. No need to manage individual tokens for broadcast scenarios.
Subscribe to Topics — React Native
// src/services/notifications.ts
import messaging from '@react-native-firebase/messaging';
/**
* Subscribe the current device to a topic.
* Topic names can only contain letters, numbers, and hyphens.
*/
export async function subscribeToTopic(topic: string): Promise<void> {
await messaging().subscribeToTopic(topic);
console.log(`Subscribed to topic: ${topic}`);
}
export async function unsubscribeFromTopic(topic: string): Promise<void> {
await messaging().unsubscribeFromTopic(topic);
console.log(`Unsubscribed from topic: ${topic}`);
}
// Usage examples:
// await subscribeToTopic('sports-cricket');
// await subscribeToTopic('news-breaking');
// await subscribeToTopic('app-updates');
NestJS — Send to Topic
// src/notifications/notifications.service.ts
import { Injectable, Inject, Logger } from '@nestjs/common';
import * as admin from 'firebase-admin';
export interface NotificationPayload {
title: string;
body: string;
data?: Record<string, string>;
imageUrl?: string;
}
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private readonly messaging: admin.messaging.Messaging;
constructor(
@Inject('FIREBASE_ADMIN') private readonly firebaseApp: admin.app.App,
) {
this.messaging = firebaseApp.messaging();
}
/**
* Send a notification to ALL subscribers of a topic
* @param topic e.g. 'sports-cricket', 'news-breaking'
*/
async sendToTopic(
topic: string,
payload: NotificationPayload,
): Promise<string> {
const message: admin.messaging.Message = {
topic,
notification: {
title: payload.title,
body: payload.body,
imageUrl: payload.imageUrl,
},
data: payload.data ?? {},
android: {
notification: {
channelId: 'default_channel',
priority: 'high',
sound: 'default',
},
},
apns: {
payload: {
aps: {
sound: 'default',
badge: 1,
contentAvailable: true,
},
},
fcmOptions: {
imageUrl: payload.imageUrl,
},
},
};
try {
const response = await this.messaging.send(message);
this.logger.log(`Topic message sent to '${topic}': ${response}`);
return response;
} catch (error) {
this.logger.error(`Failed to send topic message:`, error);
throw error;
}
}
/**
* Subscribe device tokens to a topic from server side
* Useful for migrating existing token lists to topics
*/
async subscribeTokensToTopic(
tokens: string[],
topic: string,
): Promise<admin.messaging.TopicManagementResponse> {
return this.messaging.subscribeToTopic(tokens, topic);
}
}
Condition-Based Topics
FCM supports boolean conditions on topics — extremely powerful for segmentation:
async sendWithCondition(payload: NotificationPayload): Promise<string> {
const message: admin.messaging.Message = {
// Send to users subscribed to 'sports' OR 'cricket'
// but NOT 'muted-users'
condition: "'sports' in topics || 'cricket' in topics && !('muted-users' in topics)",
notification: {
title: payload.title,
body: payload.body,
},
data: payload.data,
};
return this.messaging.send(message);
}
9. Specific User Notifications
For personal notifications (account alerts, DMs, OTPs, order updates), you target a specific FCM registration token stored in your database.
Single Device Notification
/**
* Send to a specific device by FCM token
*/
async sendToDevice(
fcmToken: string,
payload: NotificationPayload,
): Promise<string> {
const message: admin.messaging.Message = {
token: fcmToken,
notification: {
title: payload.title,
body: payload.body,
},
data: {
// All data values must be strings!
...payload.data,
timestamp: new Date().toISOString(),
},
android: {
priority: 'high',
notification: {
channelId: 'default_channel',
priority: 'max',
vibrateTimingsMillis: [0, 250, 250, 250],
sound: 'default',
},
},
apns: {
headers: { 'apns-priority': '10' },
payload: {
aps: {
alert: { title: payload.title, body: payload.body },
sound: 'default',
badge: 1,
},
},
},
};
try {
return await this.messaging.send(message);
} catch (error: any) {
// Remove stale token from database
if (error.code === 'messaging/registration-token-not-registered') {
await this.removeStaleToken(fcmToken);
}
throw error;
}
}
/**
* Send to multiple devices (multicast — max 500 tokens per call)
*/
async sendMulticast(
tokens: string[],
payload: NotificationPayload,
): Promise<admin.messaging.BatchResponse> {
const multicastMsg: admin.messaging.MulticastMessage = {
tokens: tokens.slice(0, 500), // FCM limit
notification: { title: payload.title, body: payload.body },
data: payload.data,
};
const response = await this.messaging.sendEachForMulticast(multicastMsg);
// Collect and remove failed tokens
const staleTokens: string[] = [];
response.responses.forEach((res, idx) => {
if (
!res.success &&
res.error?.code === 'messaging/registration-token-not-registered'
) {
staleTokens.push(tokens[idx]);
}
});
if (staleTokens.length) {
await this.removeStaleTokensBatch(staleTokens);
}
this.logger.log(
`Multicast: ${response.successCount} success, ${response.failureCount} failed`,
);
return response;
}
NestJS Controller
// src/notifications/notifications.controller.ts
import { Controller, Post, Body, UseGuards, HttpCode } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { IsString, IsOptional, IsObject } from 'class-validator';
export class SendTopicDto {
@IsString() topic: string;
@IsString() title: string;
@IsString() body: string;
@IsOptional() @IsObject() data?: Record<string, string>;
}
export class SendDeviceDto {
@IsString() token: string;
@IsString() title: string;
@IsString() body: string;
@IsOptional() @IsObject() data?: Record<string, string>;
}
@Controller('notifications')
@UseGuards(JwtAuthGuard)
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Post('topic')
@HttpCode(200)
async sendToTopic(@Body() dto: SendTopicDto) {
return this.notificationsService.sendToTopic(dto.topic, dto);
}
@Post('device')
@HttpCode(200)
async sendToDevice(@Body() dto: SendDeviceDto) {
return this.notificationsService.sendToDevice(dto.token, dto);
}
}
10. Handling Incoming Messages
React Native Firebase differentiates between three states:
| State | Description | Handler |
|---|---|---|
| Foreground | App is open and active | messaging().onMessage() |
| Background | App is open but backgrounded | messaging().onNotificationOpenedApp() |
| Quit | App is completely closed | messaging().getInitialNotification() |
Notification Handler Hook
// src/hooks/useNotificationHandlers.ts
import { useEffect } from 'react';
import messaging from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from '@notifee/react-native';
import { useNavigation } from '@react-navigation/native';
export function useNotificationHandlers() {
const navigation = useNavigation();
useEffect(() => {
// 1. FOREGROUND: App is open — show a local notification via Notifee
const unsubFg = messaging().onMessage(async (remoteMessage) => {
console.log('Foreground message:', remoteMessage);
await displayNotification(remoteMessage);
});
// 2. BACKGROUND TAP: User tapped a notification while app was backgrounded
const unsubBg = messaging().onNotificationOpenedApp((remoteMessage) => {
console.log('Background tap:', remoteMessage);
handleNavigation(remoteMessage.data, navigation);
});
// 3. QUIT STATE: App was closed when notification was tapped
messaging().getInitialNotification().then((remoteMessage) => {
if (remoteMessage) {
console.log('Quit state tap:', remoteMessage);
handleNavigation(remoteMessage.data, navigation);
}
});
return () => {
unsubFg();
unsubBg();
};
}, [navigation]);
}
async function displayNotification(remoteMessage: any) {
const channelId = await notifee.createChannel({
id: 'default_channel',
name: 'Default Notifications',
sound: 'default',
importance: AndroidImportance.HIGH,
});
await notifee.displayNotification({
title: remoteMessage.notification?.title,
body: remoteMessage.notification?.body,
data: remoteMessage.data,
android: {
channelId,
smallIcon: 'ic_notification',
pressAction: { id: 'default' },
},
});
}
function handleNavigation(data: any, navigation: any) {
switch (data?.screen) {
case 'order':
navigation.navigate('OrderDetails', { orderId: data.orderId });
break;
case 'chat':
navigation.navigate('ChatRoom', { roomId: data.roomId });
break;
default:
navigation.navigate('Home');
}
}
Background & Quit State Handler
⚠️ This must be registered in
index.js(the root entry file), outside of any React component. It runs in a background thread even when the app is killed.
// index.js (project root)
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
import messaging from '@react-native-firebase/messaging';
import notifee, { EventType } from '@notifee/react-native';
/**
* Background FCM message handler.
* MUST be registered OUTSIDE of any component.
*/
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
console.log('Background message received:', remoteMessage);
// For data-only messages, manually display with Notifee
if (!remoteMessage.notification) {
const channelId = await notifee.createChannel({
id: 'default_channel',
name: 'Default Notifications',
});
await notifee.displayNotification({
title: remoteMessage.data?.title,
body: remoteMessage.data?.body,
data: remoteMessage.data,
android: { channelId },
});
}
});
// Notifee background event handler
notifee.onBackgroundEvent(async ({ type, detail }) => {
if (type === EventType.PRESS) {
console.log('User pressed background notification', detail);
}
});
AppRegistry.registerComponent(appName, () => App);
11. Rich Notifications with Notifee
@notifee/react-native provides the most powerful notification display API for React Native. Use it for action buttons, images, progress bars, and precise Android channel control.
Notification Channels & Action Buttons
// src/services/notifeeService.ts
import notifee, {
AndroidImportance,
AndroidCategory,
AndroidVisibility,
EventType,
} from '@notifee/react-native';
// Create notification channels (Android only)
export async function createNotificationChannels() {
await notifee.createChannelGroup({ id: 'alerts', name: 'Alerts' });
// High priority — for urgent alerts
await notifee.createChannel({
id: 'order_updates',
name: 'Order Updates',
groupId: 'alerts',
importance: AndroidImportance.HIGH,
vibration: true,
sound: 'default',
});
await notifee.createChannel({
id: 'promotions',
name: 'Promotions',
groupId: 'alerts',
importance: AndroidImportance.DEFAULT,
vibration: false,
});
}
// Notification with action buttons
export async function showOrderNotification(orderId: string) {
await notifee.displayNotification({
id: `order_${orderId}`,
title: 'Order Confirmed 🎉',
body: `Your order #${orderId} has been confirmed and is being prepared.`,
data: { screen: 'order', orderId },
android: {
channelId: 'order_updates',
smallIcon: 'ic_notification',
largeIcon: 'https://yourapp.com/order-icon.png',
category: AndroidCategory.STATUS,
visibility: AndroidVisibility.PUBLIC,
pressAction: { id: 'default' },
actions: [
{
title: 'View Order',
pressAction: { id: 'view_order', launchActivity: 'default' },
},
{
title: 'Track Package',
pressAction: { id: 'track', launchActivity: 'default' },
},
],
},
ios: {
categoryId: 'order',
sound: 'default',
foregroundPresentationOptions: {
banner: true,
sound: true,
badge: true,
},
},
});
}
12. Testing & Debugging
Firebase Console (Quickest Method)
Firebase Console → Cloud Messaging → Send your first message → Select your app → Enter your device's FCM token (logged from getToken()). Great for confirming end-to-end delivery without writing any server code.
cURL Test via FCM v1 REST API
# Get an access token from your service account
ACCESS_TOKEN=$(gcloud auth print-access-token)
# Send a test notification to a topic
curl -X POST \
https://fcm.googleapis.com/v1/projects/YOUR-PROJECT-ID/messages:send \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": {
"topic": "sports-cricket",
"notification": {
"title": "Test Notification",
"body": "This is a topic test message"
},
"data": {
"screen": "home",
"type": "test"
}
}
}'
Common Issues & Fixes
| Problem | Platform | Solution |
|---|---|---|
Token is null
|
iOS | Physical device required. Verify APNs key is uploaded in Firebase console. |
| No foreground notifications | Both | FCM doesn't auto-show foreground notifications. Use Notifee's displayNotification inside the onMessage handler. |
UNREGISTERED error |
Both | Token has expired. Delete it from the DB and prompt the user to re-open the app. |
| No background notifications | Android | Ensure setBackgroundMessageHandler is in index.js, not inside any React component. |
google-services.json not found |
Android | File must be in android/app/, not the project root. |
| Badge count not updating | iOS | Set badge in apns.payload.aps or call Notifee's setBadgeCount(). |
| Permission denied (Android 13+) | Android | Ensure POST_NOTIFICATIONS permission is in AndroidManifest.xml and call requestPermission() at runtime. |
| Topic subscription fails | Both | Topic names can only contain letters, numbers, and hyphens — no spaces or special characters. |
Useful Debug Snippets
// Print FCM token on app launch — copy it for Firebase Console testing
useEffect(() => {
messaging().getToken().then(token => {
console.log('========== FCM TOKEN ==========');
console.log(token);
console.log('================================');
});
}, []);
// Check current permission status
const checkPermission = async () => {
const status = await messaging().hasPermission();
console.log('Permission status:', status);
// -1 = NOT_DETERMINED, 0 = DENIED, 1 = AUTHORIZED, 2 = PROVISIONAL
};
Use
messaging().getToken()wrapped in a console log during development. Print the token, copy it, and use the Firebase Console to send a targeted test notification in seconds — no server needed for initial validation.
Top comments (0)