DEV Community

Md Shahjalal
Md Shahjalal

Posted on

Firebase Push Notifications with React Native CLI & NestJS

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

  1. Architecture Overview
  2. Firebase Project Setup
  3. React Native CLI Installation
  4. Android Configuration
  5. iOS Configuration
  6. Token Management
  7. NestJS Backend Service
  8. Topic-Based Notifications
  9. Specific User Notifications
  10. Handling Incoming Messages
  11. Rich Notifications with Notifee
  12. 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   │
└─────────────────┘                             └──────────────────┘
Enter fullscreen mode Exit fullscreen mode

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 .gitignore immediately. 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 ..
Enter fullscreen mode Exit fullscreen mode

Note: @react-native-firebase/messaging handles FCM token registration, topic subscription, and background message receipt. @notifee/react-native handles 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')
  }
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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-registered errors 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
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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,
      },
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  }'
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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)