Push Notifications in Expo React Native with Firebase Cloud Messaging — A Complete Guide for iOS & Android

What if your managed Expo app could send reliable push notifications on both iOS and Android using Firebase — without ever ejecting or touching native folders?

In a managed Expo React Native project, the android/ and ios/ directories don’t exist in your repository. Everything is generated at build time via app.config.js (or app.config.ts) and Expo Config Plugins. This is fantastic for developer experience — but it introduces real complexity when integrating Firebase Cloud Messaging (FCM) alongside expo-notifications.

In this guide, we walk through the complete implementation of push notifications for a healthcare mobile app called HealthSync — covering token generation, foreground/background handling, backend delivery via Firebase Admin SDK, and the custom Expo plugins we built to make it all work without ejecting.


Table of Contents

  1. Why Firebase + Expo Notifications Together?
  2. The Token Generation Problem — iOS vs Android
  3. Project Setup & Dependencies
  4. Firebase Console Setup
  5. Apple Developer Console Setup (iOS)
  6. Dynamic Firebase Config via app.config.ts
  7. Custom Expo Config Plugins
  8. The Push Notification Service — Full Implementation
  9. Handling Notifications Across App States
  10. Backend — Sending Notifications via Firebase Admin SDK
  11. Backend — Device Token Management
  12. Key Takeaways

1. Why Firebase + Expo Notifications Together?

You might ask — why not just use expo-notifications for everything? Or just Firebase Messaging?

The answer: neither one alone covers all the requirements.

Feature expo-notifications Firebase Messaging
Foreground notification display Built-in handler Requires native delegate setup
iOS FCM token generation Does not generate FCM tokens Native FCM token via getToken()
Android device token getDevicePushTokenAsync() works perfectly Also works, but Expo’s API is cleaner
Background handling (iOS) Limited onNotificationOpenedApp() and getInitialNotification()
Notification tap listeners addNotificationResponseReceivedListener() Requires custom handling
Managed Expo compatibility First-class Requires config plugins

In short: We use expo-notifications for permission management, foreground display, tap handling, and Android token generation. We use @react-native-firebase/messaging specifically for iOS token generation and iOS background/killed-state notification handling.


2. The Token Generation Problem — iOS vs Android

This was the most difficult issue we encountered.

The Problem

When using expo-notifications with Notifications.getDevicePushTokenAsync(), the token generated on Android works perfectly with Firebase Cloud Messaging. However, on iOS, this call returns an APNs device token — not an FCM registration token.

When the backend tries to send a notification using firebaseAdmin.messaging().send() with an APNs token, it fails because FCM expects an FCM registration token, not a raw APNs token.

The Solution

We use platform-specific token generation:

// services/pushNotificationService.ts

import {
  getMessaging,
  getToken,
} from "@react-native-firebase/messaging";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";

async function getPushToken(): Promise<string | null> {
  // iOS: Use Firebase Messaging to get an FCM token directly
  if (Platform.OS === "ios") {
    const token = await getToken(getMessaging());
    return token || null;
  }

  // Android: Expo's native device token works with FCM
  const tokenResult = await Notifications.getDevicePushTokenAsync();
  return tokenResult.data || null;
}

Why this works:

  • On iOS, getToken(getMessaging()) from @react-native-firebase/messaging internally handles the APNs-to-FCM token exchange. It registers with APNs, gets the APNs token, sends it to Firebase servers, and returns a valid FCM registration token.
  • On Android, Notifications.getDevicePushTokenAsync() from expo-notifications returns the native FCM registration token directly, which is exactly what Firebase Admin SDK expects.

Key insight: If you’re using Firebase as your push notification backend and your Expo app uses both expo-notifications and @react-native-firebase/messaging, you must use Firebase’s getToken() for iOS. The Expo-generated token will not work with FCM’s send() API on iOS.


3. Project Setup & Dependencies

Install Dependencies

npx expo install expo-notifications @react-native-firebase/app @react-native-firebase/messaging expo-build-properties

Key Versions (at time of writing)

{
  "@react-native-firebase/app": "^23.8.6",
  "@react-native-firebase/messaging": "^23.8.6",
  "expo-notifications": "~0.32.15"
}

Required Environment Variables

Create a .env file with your Firebase project credentials:

# Firebase (shared)
GCM_SENDER_ID=your_sender_id
FIREBASE_PROJECT_ID=your_project_id
FIREBASE_STORAGE_BUCKET=your_project.appspot.com

# Android-specific
FIREBASE_APP_ID=1:xxxx:android:xxxx
FIREBASE_API_KEY=AIzaSy...

# iOS-specific
GOOGLE_APP_ID=1:xxxx:ios:xxxx
API_KEY=AIzaSy...

4. Firebase Console Setup

Step 1: Create a Firebase Project

  1. Go to Firebase Console
  2. Click “Add project” and follow the wizard
  3. Give your project a name (e.g., healthsync-app)
  4. Enable or disable Google Analytics as needed
  5. Click “Create project”

Step 2: Add an Android App

  1. In the Firebase project, click “Add app” → select Android
  2. Enter your Android package name (e.g., com.healthsync.patient)
  3. Optionally add the app nickname and SHA-1 signing certificate
  4. Click “Register app”
  5. Download google-services.json — note the values for GCM_SENDER_ID, FIREBASE_APP_ID, FIREBASE_API_KEY, FIREBASE_PROJECT_ID, and FIREBASE_STORAGE_BUCKET. You’ll use these as environment variables (we generate the file dynamically at build time, so you don’t commit it)
  6. Skip the “Add Firebase SDK” steps (we handle this via Expo plugins)

Step 3: Add an iOS App

  1. Click “Add app” → select iOS
  2. Enter your iOS bundle ID (e.g., com.healthsync.patient)
  3. Click “Register app”
  4. Download GoogleService-Info.plist — note the values for API_KEY, GCM_SENDER_ID, GOOGLE_APP_ID, FIREBASE_PROJECT_ID, and FIREBASE_STORAGE_BUCKET. Again, we generate this file dynamically
  5. Skip the remaining setup steps

Step 4: Enable Cloud Messaging

  1. Go to Project SettingsCloud Messaging tab
  2. Ensure Firebase Cloud Messaging API (V1) is enabled
  3. For iOS, you’ll need to upload your APNs authentication key (covered in the next section)

5. Apple Developer Console Setup (iOS)

For push notifications to work on iOS, Firebase needs an APNs Authentication Key to communicate with Apple’s Push Notification service on your behalf.

Step 1: Create an APNs Authentication Key

  1. Go to Apple Developer Portal
  2. Navigate to Certificates, Identifiers & ProfilesKeys
  3. Click the “+” button to create a new key
  4. Give it a name (e.g., HealthSync Push Key)
  5. Check “Apple Push Notifications service (APNs)”
  6. Click “Continue”“Register”
  7. Download the .p8 key file — save it securely, you can only download it once
  8. Note the Key ID displayed on the page

Step 2: Note Your Team ID

  1. Go to Membership Details in the Apple Developer Portal
  2. Note your Team ID (a 10-character alphanumeric string)

Step 3: Upload APNs Key to Firebase

  1. Go to Firebase ConsoleProject SettingsCloud Messaging tab
  2. Under Apple app configuration, click “Upload” next to APNs Authentication Key
  3. Upload the .p8 file you downloaded
  4. Enter the Key ID and your Team ID
  5. Click “Upload”

Important: Without this step, Firebase cannot deliver push notifications to iOS devices. The APNs key allows Firebase to authenticate with Apple’s push notification service.

Step 4: Configure App ID for Push Notifications

  1. In the Apple Developer Portal, go to Identifiers
  2. Select your app’s App ID (or create one matching your bundle identifier)
  3. Under Capabilities, ensure “Push Notifications” is checked
  4. If using a provisioning profile, regenerate it after enabling push notifications

6. Dynamic Firebase Config via app.config.ts

Since we’re in a managed Expo workflow with no native directories in the repo, Firebase config files (google-services.json for Android and GoogleService-Info.plist for iOS) must be generated at build time.

Here’s how we configure app.config.ts:

// app.config.ts

import dotenv from "dotenv";
import * as fs from "fs";
import * as path from "path";

dotenv.config();

export default {
  name: "HealthSync",
  slug: "healthsync-patient",
  // ... other config

  ios: {
    bundleIdentifier: "com.healthsync.patient",
    googleServicesFile: "./GoogleService-Info.plist",
    infoPlist: {
      NSUserNotificationUsageDescription:
        "We use notifications to keep you updated about important alerts.",
      UIBackgroundModes: ["remote-notification", "fetch"],
    },
  },

  android: {
    package: "com.healthsync.patient",
    googleServicesFile: "./google-services.json",
  },

  plugins: [
    [
      "expo-notifications",
      {
        icon: "./assets/icons/notification-icon.png",
        color: "#ffffff",
        defaultChannel: "default",
        enableBackgroundRemoteNotifications: true,
      },
    ],
    "@react-native-firebase/messaging",
    "@react-native-firebase/app",
    withIOSForegroundNotifications,   // Custom plugin
    withFirebaseManifestFix,          // Custom plugin
    withFirebasePodfileFix,           // Custom plugin
    withFirebaseConfigAndroid,        // Custom plugin
    withFirebaseConfigIOS,            // Custom plugin
    [
      "expo-build-properties",
      {
        ios: {
          useFrameworks: "static",
        },
      },
    ],
  ],
};

Key points about the config:

  • googleServicesFile points to the root-level file — our custom plugins generate these files at build time from environment variables
  • UIBackgroundModes: ["remote-notification", "fetch"] — required for iOS to receive notifications in the background
  • enableBackgroundRemoteNotifications: true — tells expo-notifications to support remote background notifications
  • useFrameworks: "static" — required by @react-native-firebase when used with Expo

7. Custom Expo Config Plugins

Since we cannot manually edit native files in a managed Expo project, we create Config Plugins that modify the native code during the prebuild step. Here are the five custom plugins we built:

7.1 withFirebaseConfigAndroid — Generate google-services.json

This plugin dynamically creates the google-services.json file from environment variables:

import { ConfigPlugin } from "@expo/config-plugins";
import * as fs from "fs";
import * as path from "path";

const withFirebaseConfigAndroid: ConfigPlugin = (config) => {
  const projectRoot = config._internal?.projectRoot || process.cwd();
  const rootPath = path.join(projectRoot, "google-services.json");
  const bundleId = "com.healthsync.patient";

  const googleServicesJson = {
    project_info: {
      project_number: process.env.GCM_SENDER_ID,
      project_id: process.env.FIREBASE_PROJECT_ID,
      storage_bucket: process.env.FIREBASE_STORAGE_BUCKET,
    },
    client: [
      {
        client_info: {
          mobilesdk_app_id: process.env.FIREBASE_APP_ID,
          android_client_info: { package_name: bundleId },
        },
        oauth_client: [],
        api_key: [{ current_key: process.env.FIREBASE_API_KEY }],
        services: {
          appinvite_service: { other_platform_oauth_client: [] },
        },
      },
    ],
    configuration_version: "1",
  };

  fs.writeFileSync(rootPath, JSON.stringify(googleServicesJson, null, 2));
  return config;
};

7.2 withFirebaseConfigIOS — Generate GoogleService-Info.plist

Similarly, this generates the iOS Firebase config:

const withFirebaseConfigIOS: ConfigPlugin = (config) => {
  const projectRoot = config._internal?.projectRoot || process.cwd();
  const rootPath = path.join(projectRoot, "GoogleService-Info.plist");
  const bundleId = "com.healthsync.patient";

  const plistContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>API_KEY</key>
  <string>${process.env.API_KEY}</string>
  <key>GCM_SENDER_ID</key>
  <string>${process.env.GCM_SENDER_ID}</string>
  <key>PLIST_VERSION</key>
  <string>1</string>
  <key>BUNDLE_ID</key>
  <string>${bundleId}</string>
  <key>PROJECT_ID</key>
  <string>${process.env.FIREBASE_PROJECT_ID}</string>
  <key>STORAGE_BUCKET</key>
  <string>${process.env.FIREBASE_STORAGE_BUCKET}</string>
  <key>IS_ADS_ENABLED</key>
  <false></false>
  <key>IS_ANALYTICS_ENABLED</key>
  <false></false>
  <key>IS_APPINVITE_ENABLED</key>
  <true></true>
  <key>IS_GCM_ENABLED</key>
  <true></true>
  <key>IS_SIGNIN_ENABLED</key>
  <true></true>
  <key>GOOGLE_APP_ID</key>
  <string>${process.env.GOOGLE_APP_ID}</string>
</dict>
</plist>`;

  fs.writeFileSync(rootPath, plistContent);
  return config;
};

7.3 withIOSForegroundNotifications — iOS Foreground Display

By default, iOS does not display notifications when the app is in the foreground. This plugin injects the UNUserNotificationCenterDelegate into the AppDelegate to handle foreground presentation:

import { ConfigPlugin, withAppDelegate } from "@expo/config-plugins";

const withIOSForegroundNotifications: ConfigPlugin = (config) =>
  withAppDelegate(config, (config) => {
    let src = config.modResults.contents;

    // Import UserNotifications framework
    if (!src.includes("import UserNotifications"))
      src = src.replace(
        /import ReactAppDependencyProvider/,
        "import ReactAppDependencyProvider\nimport UserNotifications"
      );

    // Conform AppDelegate to UNUserNotificationCenterDelegate
    if (!src.includes("UNUserNotificationCenterDelegate"))
      src = src.replace(
        /public class AppDelegate: ExpoAppDelegate/,
        "public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate"
      );

    // Set delegate in didFinishLaunchingWithOptions
    if (!src.includes("UNUserNotificationCenter.current().delegate = self"))
      src = src.replace(
        /(return super\.application\(application, didFinishLaunchingWithOptions: launchOptions\))/,
        "UNUserNotificationCenter.current().delegate = self\n\n    $1"
      );

    // Add willPresent delegate method to show banner + sound + badge
    if (!src.includes("userNotificationCenter(_:willPresent:"))
      src = src.replace(
        /(  \}\n\}\n\nclass ReactNativeDelegate)/,
        `  }

  @objc public func userNotificationCenter(
    _ center: UNUserNotificationCenter,
    willPresent notification: UNNotification,
    withCompletionHandler completionHandler:
      @escaping (UNNotificationPresentationOptions) -> Void
  ) {
    completionHandler([.banner, .sound, .badge])
  }
}

class ReactNativeDelegate`
      );

    config.modResults.contents = src;
    return config;
  });

Why this is needed: Without this delegate, iOS silently suppresses foreground notifications. The completionHandler([.banner, .sound, .badge]) call tells iOS to display them with full visual and audio feedback.

7.4 withFirebaseManifestFix — Android Manifest Merger Conflict

When both expo-notifications and @react-native-firebase/messaging are installed, they both try to set default notification channel, icon, and color metadata in AndroidManifest.xml. This causes a manifest merger conflict at build time.

This plugin adds tools:replace attributes to resolve the conflict:

import { ConfigPlugin, withFinalizedMod } from "@expo/config-plugins";
import * as fs from "fs";
import * as path from "path";

const withFirebaseManifestFix: ConfigPlugin = (config) =>
  withFinalizedMod(config, [
    "android",
    async (config) => {
      const manifestPath = path.join(
        config.modRequest.platformProjectRoot,
        "app/src/main/AndroidManifest.xml"
      );
      let content = fs.readFileSync(manifestPath, "utf8");

      // Add tools namespace if not present
      if (!content.includes("xmlns:tools"))
        content = content.replace(
          'xmlns:android="http://schemas.android.com/apk/res/android"',
          '$& xmlns:tools="http://schemas.android.com/tools"'
        );

      // Add tools:replace to resolve duplicate metadata entries
      content = content
        .replace(
          /default_notification_channel_id"[^>]+\/>/,
          'default_notification_channel_id" android:value="default" tools:replace="android:value"/>'
        )
        .replace(
          /default_notification_color"[^>]+\/>/,
          'default_notification_color" android:resource="@color/notification_icon_color" tools:replace="android:resource"/>'
        )
        .replace(
          /default_notification_icon"[^>]+\/>/,
          'default_notification_icon" android:resource="@drawable/notification_icon" tools:replace="android:resource"/>'
        );

      fs.writeFileSync(manifestPath, content);
      return config;
    },
  ]);

7.5 withFirebasePodfileFix — iOS Build Error Fix

When using useFrameworks: "static" (required by @react-native-firebase), you may encounter a CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES build error. This plugin patches the Podfile:

import { ConfigPlugin, withPodfile } from "@expo/config-plugins";

const withFirebasePodfileFix: ConfigPlugin = (config) =>
  withPodfile(config, (config) => {
    const src = config.modResults.contents;
    const patch = `    installer.pods_project.targets.each do |target|
      target.build_configurations.each do |c|
        c.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
      end
    end`;

    if (src.includes("CLANG_ALLOW_NON_MODULAR_INCLUDES")) return config;

    const anchor =
      /(react_native_post_install\([\s\S]*?)(\s+\))\s*\n(\s+end)/m;
    config.modResults.contents = src.replace(
      anchor,
      (_, block, close, end) => `${block}${close}\n${patch}\n${end}`
    );
    return config;
  });

8. The Push Notification Service — Full Implementation

Here’s the complete push notification service that ties everything together:

// services/pushNotificationService.ts

import AsyncStorage from "@react-native-async-storage/async-storage";
import {
  getInitialNotification,
  getMessaging,
  getToken,
  onMessage,
  onNotificationOpenedApp,
} from "@react-native-firebase/messaging";
import * as Notifications from "expo-notifications";
import { AppState, AppStateStatus, Platform } from "react-native";
import apiService from "./apiEndpoints";

// --- Token Generation (Platform-Specific) ---

async function getPushToken(): Promise<string | null> {
  if (Platform.OS === "ios") {
    const token = await getToken(getMessaging());
    return token || null;
  }
  const tokenResult = await Notifications.getDevicePushTokenAsync();
  return tokenResult.data || null;
}

const DEVICE_TOKEN_KEY = "DeviceToken";
const PERMISSION_DENIED_KEY = "NotificationPermissionDenied";

// --- Foreground Notification Display ---

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldPlaySound: true,
    shouldSetBadge: true,
    shouldShowBanner: true,
    shouldShowList: true,
    shouldAnnotate: true,
    shouldShowCarPlay: false,
  }),
});

// --- Permission & Registration ---

export async function registerAndSendToken(): Promise<void> {
  const { status: existingStatus } =
    await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== "granted") {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
    if (finalStatus !== "granted") {
      await AsyncStorage.setItem(PERMISSION_DENIED_KEY, "true");
      return;
    }
  }

  try {
    const token = await getPushToken();
    if (!token) return;

    // Only register if user is logged in
    const isLoggedIn = await AsyncStorage.getItem("IsLoggedIn");
    if (isLoggedIn !== "true") return;

    // Avoid re-registering the same token
    const storedToken = await AsyncStorage.getItem(DEVICE_TOKEN_KEY);
    if (storedToken === token) return;

    await apiService.sendDeviceToken(token);
    await AsyncStorage.setItem(DEVICE_TOKEN_KEY, token);
    await AsyncStorage.removeItem(PERMISSION_DENIED_KEY);
  } catch (error) {
    console.error("Push registration failed:", error);
  }
}

// --- Permission Retry (User Grants from Settings) ---

export async function checkAndRetryRegistration(): Promise<void> {
  const wasDenied = await AsyncStorage.getItem(PERMISSION_DENIED_KEY);
  if (wasDenied !== "true") return;

  const { status } = await Notifications.getPermissionsAsync();
  if (status === "granted") {
    await registerAndSendToken();
  }
}

export function setupAppStateListener(): () => void {
  const subscription = AppState.addEventListener(
    "change",
    (nextAppState: AppStateStatus) => {
      if (nextAppState === "active") {
        checkAndRetryRegistration();
      }
    }
  );
  return () => subscription.remove();
}

// --- Logout Cleanup ---

export async function removeDeviceTokenOnLogout(): Promise<void> {
  try {
    const token = await AsyncStorage.getItem(DEVICE_TOKEN_KEY);
    if (token) {
      await apiService.deleteDeviceToken(token);
    }
  } catch (error) {
    console.error("Delete device token failed:", error);
  }
  await AsyncStorage.removeItem(DEVICE_TOKEN_KEY);
}

9. Handling Notifications Across App States

Notifications behave differently depending on whether the app is in the foreground, background, or killed. Here’s how each case is handled:

Foreground: Android uses onMessage() to schedule locally; iOS is handled by the AppDelegate delegate.
Background: Both platforms display in the system tray automatically. Killed state: iOS uses Firebase’s getInitialNotification(), Android uses Expo’s getLastNotificationResponseAsync().

Foreground Listener

export function setupForegroundMessageListener(): () => void {
  const unsubscribe = onMessage(getMessaging(), async (remoteMessage) => {
    // iOS: The native AppDelegate delegate (our custom plugin)
    // already displays the notification. No action needed.
    if (Platform.OS === "android") {
      // Android: Firebase onMessage doesn't display notifications
      // in foreground. We schedule it via Expo to show the banner.
      await Notifications.scheduleNotificationAsync({
        content: {
          title: remoteMessage.notification?.title || "Notification",
          body: remoteMessage.notification?.body || "",
          data: remoteMessage.data || {},
        },
        trigger: null, // Immediate display
      });
    }
  });
  return unsubscribe;
}

iOS Background Tap Handler

export function setupIOSBackgroundNotificationListener(): () => void {
  if (Platform.OS !== "ios") return () => {};

  const unsubscribe = onNotificationOpenedApp(
    getMessaging(),
    (remoteMessage) => {
      if (!remoteMessage) return;

      const title = remoteMessage.notification?.title;
      const data = remoteMessage.data || {};
      const type =
        (typeof data.type === "string" ? data.type : undefined) ??
        (title?.includes("Alert") ? "alerts" : "notification");

      if (notificationTapCallback) {
        notificationTapCallback(type);
      }
    }
  );

  return unsubscribe;
}

Killed State — Initial Notification

export async function getLastNotificationResponse(): Promise<NotificationResponse | null> {
  if (Platform.OS === "ios") {
    // iOS: Use Firebase to get the notification that launched the app
    const remoteMessage = await getInitialNotification(getMessaging());
    if (remoteMessage) {
      const title = remoteMessage.notification?.title;
      const data = remoteMessage.data || {};
      const type =
        (typeof data.type === "string" ? data.type : undefined) ??
        (title?.includes("Alert") ? "alerts" : "notification");

      // Convert to Expo's NotificationResponse format for consistency
      return {
        actionIdentifier: Notifications.DEFAULT_ACTION_IDENTIFIER,
        notification: {
          request: {
            content: {
              data: { ...data, type },
              title,
              body: remoteMessage.notification?.body,
            },
            trigger: null,
          },
        },
      } as unknown as Notifications.NotificationResponse;
    }
    return null;
  }

  // Android: Expo handles killed-state notifications natively
  return Notifications.getLastNotificationResponseAsync();
}

Notification Tap Listener

export function addNotificationResponseListener(): () => void {
  const sub = Notifications.addNotificationResponseReceivedListener(
    (response) => {
      const data = response.notification.request.content.data;
      const aps = data?.aps as Record<string, unknown> | undefined;
      const title = response.notification.request.content.title;

      // Extract type from multiple possible payload locations
      let type =
        (typeof data?.type === "string" ? data.type : undefined) ??
        (typeof aps?.type === "string" ? aps.type : undefined);

      // Fallback: check trigger payload (iOS-specific structure)
      if (!type && response.notification.request.trigger) {
        const trigger = response.notification.request.trigger as any;
        type =
          trigger.payload?.type ??
          trigger.payload?.aps?.type;
      }

      // Final fallback: derive from title
      if (!type) {
        type = title?.includes("Alert") ? "alerts" : "notification";
      }

      if (type && notificationTapCallback) {
        notificationTapCallback(type);
      }
    }
  );
  return () => sub.remove();
}

Why the multi-layered type extraction? Notification payloads arrive in different structures depending on the platform, app state, and whether the notification was delivered via Firebase directly or scheduled locally via Expo. The type field can be in data.type, data.aps.type, trigger.payload.type, or trigger.payload.aps.type. This cascading extraction ensures we always route the user to the correct screen.


10. Backend — Sending Notifications via Firebase Admin SDK

The backend uses Firebase Admin SDK (Node.js) to send notifications. Firebase service account credentials are stored securely in AWS S3 and fetched at runtime.

Firebase Helper — Send Notification

// lib/firebase_helper.js

const AWS = require("aws-sdk");
const firebaseAdmin = require("firebase-admin");

async function sendFirebaseNotification(
  bucketName,
  fileName,
  deviceToken,
  title,
  patientId,
  type,
  data = {}
) {
  const s3 = new AWS.S3();

  // Fetch Firebase service account credentials from S3
  const fileData = await s3
    .getObject({ Bucket: bucketName, Key: fileName })
    .promise();
  const credentials = JSON.parse(fileData.Body.toString("utf-8"));

  // Initialize Firebase (only once)
  if (!firebaseAdmin.apps.length) {
    firebaseAdmin.initializeApp({
      credential: firebaseAdmin.credential.cert(credentials),
    });
  }

  const notificationBody =
    `Device ID: ${data.device_id || "-"}\nReading: ${data.value || "-"}`;

  // Build platform-specific FCM message
  const message = {
    token: deviceToken,
    notification: {
      title,
      body: notificationBody,
    },
    data,
    android: {
      notification: { title, body: notificationBody },
      data: { ...data, type },
      priority: "high",
    },
    apns: {
      payload: {
        aps: {
          alert: { title, body: notificationBody },
          sound: "default",
          type, // Custom field for routing in the app
        },
      },
    },
  };

  const response = await firebaseAdmin.messaging().send(message);
  return { success: true, response };
}

Processing Multiple Readings & Sending to All Devices

async function processPayloadsAndSendNotifications(payloads, patientDetails) {
  if (
    !Array.isArray(patientDetails.device_token) ||
    patientDetails.device_token.length === 0
  ) {
    console.log("No device tokens found. Skipping notifications.");
    return;
  }

  const notificationPromises = payloads.map(async (payload) => {
    if (payload.reading_details === "-") return;

    const type =
      payload.reading_details === "Nominal" ? "notification" : "alert";

    // Map reading types to human-readable labels
    const vitalTypeMapping = {
      blood_pressure: "Blood Pressure",
      spo2: "SPO2",
      blood_glucose: "Blood Glucose",
      pulse: "Pulse",
      weight: "Weight",
      temperature: "Temperature",
    };

    const vitalType = vitalTypeMapping[payload.reading_type] || "Unknown";

    // Generate title based on reading status
    let title;
    if (payload.reading_details === "Nominal") {
      title = `😊 Your ${vitalType} is Normal`;
    } else {
      title = `⚠️ Alert: ${vitalType} ${payload.reading_details}`;
    }

    const notificationData = {
      value: `${payload.reading || "-"} ${payload.unit || ""}`.trim(),
      device_id: payload.device_id || "-",
    };

    // Send to ALL registered device tokens for this patient
    const tokenPromises = patientDetails.device_token.map((token) =>
      sendFirebaseNotification(
        process.env.S3_BUCKET_NAME,
        process.env.FIREBASE_CREDENTIALS_FILE,
        token,
        title,
        patientDetails.patient_id,
        type,
        notificationData
      )
    );

    return Promise.all(tokenPromises);
  });

  await Promise.all(notificationPromises);
}

Automatic Invalid Token Cleanup

When Firebase returns specific error codes, the backend automatically removes the stale token:

async function removeInvalidDeviceToken(patientId, invalidToken) {
  await Patients.findOneAndUpdate(
    { patient_id: patientId },
    { $pull: { device_token: invalidToken } },
    { new: true }
  );
}

// Inside sendFirebaseNotification catch block:
if (
  [
    "messaging/registration-token-not-registered",
    "messaging/invalid-recipient",
    "messaging/invalid-argument",
  ].includes(error.code)
) {
  await removeInvalidDeviceToken(patientId, deviceToken);
}

This ensures the device token array stays clean without manual intervention.


11. Backend — Device Token Management

Register Device Token (on Login)

The mobile app sends the FCM token to the backend after successful login:

// handlers/update_device_token.js — PATCH /device-token

const user_name = event.requestContext.authorizer.claims["cognito:username"];
const user_type = event.requestContext.authorizer.claims["custom:user_type"];

// Determine the correct user model based on user type
const User = user_type === "patient-user" ? Patients : ProviderSchema;

await User.updateOne(
  { user_name },
  {
    // $addToSet prevents duplicate tokens in the array
    $addToSet: { device_token: schemaValidationData.token },
  }
);

Remove Device Token (on Logout)

When the user logs out, the app calls the delete endpoint to unregister the device:

// handlers/delete_device_token.js — DELETE /device-token/{id}

const user_name = event.requestContext.authorizer.claims["cognito:username"];
const user_type = event.requestContext.authorizer.claims["custom:user_type"];
const User = user_type === "patient-user" ? Patients : ProviderSchema;

await User.updateOne(
  { user_name },
  {
    // $pull removes the specific token from the array
    $pull: { device_token: schemaValidationData.id },
  }
);

Why $addToSet and $pull? A single user can have multiple devices. $addToSet ensures the same token isn’t added twice (idempotent registration), and $pull removes only the specific device’s token on logout without affecting other devices.


12. Key Takeaways

  1. Use Firebase’s getToken() for iOS, Expo’s getDevicePushTokenAsync() for Android. This was the single most impactful fix — Expo’s token on iOS is a raw APNs token, which doesn’t work with FCM’s send() API.

  2. Custom Expo Config Plugins are essential. In a managed Expo workflow, you cannot edit native files directly. Config plugins let you modify AppDelegate, AndroidManifest.xml, Podfile, and generate Firebase config files — all at build time.

  3. Handle all three app states explicitly. Foreground, background, and killed state each require different handling mechanisms. Don’t assume what works in one state works in another.

  4. The Android manifest merger conflict is real. Both expo-notifications and @react-native-firebase/messaging declare the same <meta-data> entries. Without tools:replace, your Android build will fail.

  5. The iOS foreground notification display requires a native delegate. Unlike Android, iOS won’t show notifications while the app is open unless you implement UNUserNotificationCenterDelegate.willPresent.

  6. Clean up device tokens automatically. When Firebase reports a token as invalid, remove it from the database immediately. This prevents wasted API calls and keeps your token store healthy.


Built with Expo SDK, React Native, Firebase Cloud Messaging, and Node.js. Tested on both iOS and Android devices in production.