DEV Community

Cover image for React Native Push Notifications: The Complete Guide for Android and iOS
Notify sphere
Notify sphere

Posted on Edited on Originally published at notifysphere.net AI-assisted

React Native Push Notifications: The Complete Guide for Android and iOS

Push notifications in React Native come down to three pieces: Firebase Cloud Messaging (FCM) to deliver messages, native setup on Android and iOS, and JavaScript that registers the device and reacts when a notification arrives or is tapped. This guide walks through all of it with the notifySphere React Native SDK, including the parts most tutorials skip: what happens when the app is closed, opening the right screen from a tap, notification channels, attribution and signing out on a shared phone.

How push notifications work in React Native

When your app starts, it asks Firebase for a push token: a long string that identifies this app on this device. Your app sends the token to a server, here notifySphere, together with the user it belongs to. When you send a campaign, the server asks FCM to deliver it. FCM reaches Android devices directly and iPhones through Apple Push Notification service (APNs). One Firebase project covers both platforms.

React Native adds one twist. Your JavaScript only runs while the app is alive. When the app is in the background or closed, the operating system shows the notification on its own, and React Native can wake a small headless JavaScript task to handle the message. That task only knows about code that runs at the top of index.js, which is why the order of setup matters so much.

The SDK takes care of most of the plumbing:

  • asking for the notification permission on Android 13+ and iOS;
  • getting the FCM token (and on iOS, waiting until the APNs token is ready first);
  • registering the device with your user ID, profile fields and tags, and only calling the API again when the token or user details change;
  • re-registering automatically when Firebase rotates the token;
  • showing notifications while the app is open, and creating Android notification channels;
  • sending delivery and tap receipts, and reading the install referrer for attribution on Android.

You handle the native project setup, the background handler, and what your app does when a user taps.

What you need

  • React Native 0.70 or newer, in a bare project or an Expo development build (not Expo Go).
  • A Firebase project with Cloud Messaging enabled, and an APNs authentication key uploaded to it for iOS.
  • A notifySphere app with an App ID and API token, and your Firebase service account uploaded under Settings → Messaging (see Firebase credentials). notifySphere is free up to 1,000 monthly active users.
  • A physical iPhone for iOS testing. Push doesn't work on the iOS Simulator.

Step 1: Install the SDK and its peer dependencies

npm install react-native-notify-sphere
npm install @react-native-firebase/app @react-native-firebase/messaging
npm install @notifee/react-native
npm install @react-native-async-storage/async-storage@^1.23.1
npm install react-native-device-info react-native-install-referrer
cd ios && pod install && cd ..
Enter fullscreen mode Exit fullscreen mode

Each peer dependency has one job:

  • @react-native-firebase/app and /messaging: the push token and incoming messages.
  • @notifee/react-native: drawing notifications while the app is open, and Android channels.
  • @react-native-async-storage/async-storage: remembering the token and subscription between launches. Pin it to v1; v2 needs a custom Maven repository.
  • react-native-device-info: device model, OS version and app version for your user list.
  • react-native-install-referrer: reading the Play Store referrer so installs are credited to campaigns.

Step 2: Set up Android

  1. Download google-services.json from the Firebase console into android/app/.
  2. Add the Google Services Gradle plugin:
// android/build.gradle
buildscript {
  dependencies {
    classpath "com.google.gms:google-services:4.4.0"
  }
}

// android/app/build.gradle (last line of the file)
apply plugin: "com.google.gms.google-services"
Enter fullscreen mode Exit fullscreen mode
  1. Declare the permissions and the default notification icon in your manifest:
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />

<application ...>
  <!-- Icon Android uses when it shows a push while your app is in the background or closed -->
  <meta-data
    android:name="com.google.firebase.messaging.default_notification_icon"
    android:resource="@drawable/ic_notification" />
</application>
Enter fullscreen mode Exit fullscreen mode
  1. Add ic_notification.png to android/app/src/main/res/drawable/. It must be a white shape on a transparent background. Android draws the small icon as a single colour, so a full-colour app icon shows up as a grey or white square.

You don't need to add a default channel yourself. The SDK's own manifest sets channel_default as Firebase's default channel, and the SDK creates that channel with high importance and the default sound on first launch. The Android 13 notification permission guide explains the permission prompt in detail.

Step 3: Set up iOS

  1. Download GoogleService-Info.plist from Firebase and drag it into your Xcode project, with “Copy items if needed” checked.
  2. Under Signing & Capabilities, add Push Notifications and Background Modes → Remote notifications.
  3. In the Apple Developer portal, create an APNs authentication key (.p8) and upload it in Firebase under Project settings → Cloud Messaging. Without it, Firebase can't reach iPhones and you get errors like “No APNs token specified”.
  4. Configure Firebase and the notification centre delegate in your AppDelegate:
// ios/YourApp/AppDelegate.mm
#import <Firebase.h>
#import <UserNotifications/UserNotifications.h>

- (BOOL)application:(UIApplication *)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  if ([FIRApp defaultApp] == nil) {
    [FIRApp configure];
  }
  [UNUserNotificationCenter currentNotificationCenter].delegate = self;
  // ...the rest of your existing setup
  return YES;
}

// Show banners, sound and badge even when the app is open
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler
{
  completionHandler(UNNotificationPresentationOptionSound |
                    UNNotificationPresentationOptionAlert |
                    UNNotificationPresentationOptionBadge);
}
Enter fullscreen mode Exit fullscreen mode

The willPresentNotification method decides whether iOS shows a banner while your app is open. Leave it out and foreground pushes arrive silently. The Firebase iOS push guide covers APNs keys and certificates step by step.

Step 4: Register the background handler

When a push arrives while the app is in the background or closed, React Native starts a headless JavaScript task that runs index.js and nothing else: no components, no useEffect, no initialize(). Firebase needs its background handler to exist at that moment, so register it synchronously at the top of index.js, before registerComponent():

// index.js
import { AppRegistry } from "react-native";
import App from "./src/App";
import { name as appName } from "./app.json";
import NotifySphere from "react-native-notify-sphere";
import { APP_ID, API_TOKEN } from "./src/config";

// Must run synchronously, before registerComponent().
// Not inside .then(), setTimeout() or an async function.
NotifySphere.setBackgroundHandler({ appId: APP_ID, apiKey: API_TOKEN });

AppRegistry.registerComponent(appName, () => App);
Enter fullscreen mode Exit fullscreen mode

Pass appId here. In a closed app, initialize() hasn't run, so this is the only place the SDK can learn where to send delivery receipts. If you wrap this call in AsyncStorage.getItem().then(...) or a timeout, it registers after the message has already been handed over, and the receipt is lost.

Step 5: Register the user and device

Call initialize() once you know who the user is. It asks for permission, gets the FCM token, registers the device and returns the subscription ID. It's safe to call on every launch: the SDK stores a fingerprint of the token and user details, and only calls the API when something changed.

// src/App.tsx
import { useEffect } from "react";
import { Platform } from "react-native";
import NotifySphere from "react-native-notify-sphere";
import { APP_ID, API_TOKEN } from "./config";

const PUSH_TYPE = Platform.OS === "android" ? "AndroidPush" : "IOSPush";

export default function App() {
  const user = useCurrentUser(); // your own auth state

  useEffect(() => {
    if (!user) return;

    NotifySphere.initialize({
      applicationUserId: user.id,
      type: PUSH_TYPE,
      appId: APP_ID,
      apiKey: API_TOKEN,
      name: user.name,
      email: user.email,
      phone: user.phone, // a string, so leading zeros and +91 survive
      tags: { plan: user.plan, city: user.city },
      debug: __DEV__,
    }).catch((e) => console.warn("Push setup failed", e));

    return () => NotifySphere.destroy();
  }, [user?.id]);

  // ...render your app
}
Enter fullscreen mode Exit fullscreen mode

A few details from the SDK source that are worth knowing:

  • Permission: on Android 13+ the SDK requests POST_NOTIFICATIONS and stops waiting after 10 seconds. On iOS it asks through Firebase and treats provisional (quiet) permission as granted. If the user says no, the device still registers, but nothing will be shown until they turn notifications on in settings. If you'd rather explain the value first, show your own screen and call initialize() after it.
  • Phone numbers are strings, so country codes and leading zeros aren't lost. They're used for WhatsApp messages to the same user.
  • HTTPS only: the SDK refuses any plain http:// endpoint, because the request carries the token and profile fields.
  • Token rotation is handled for you: when Firebase issues a new token, the SDK re-registers the device with the same user. Expired tokens are cleaned up on the server; see FCM UNREGISTERED tokens for why that matters for your bill.

Foreground, background and closed: what actually happens

Most “push works sometimes” bugs come from not knowing which code runs in which state. Here is the behaviour of SDK 1.3:

App state Who shows the notification onNotification() type Delivery receipt
Open (foreground) The SDK draws it with Notifee, so it shows as a banner like any other push received when it arrives, opened when tapped Yes
In the background Android or iOS shows it from the notification block, without running your JavaScript opened when tapped Only for data-only messages
Closed (terminated) The operating system shows it, same as background initial when tapped and the app starts Only for data-only messages, and only if setBackgroundHandler() runs in index.js

Two consequences. First, you never get duplicate notifications: since version 1.2.5 the background handler only sends the delivery receipt and leaves display to the operating system. Second, when a push carries a notification block, Android shows it in the background without running your JavaScript, so delivery receipts from background and closed states only come from data-only messages. Taps are tracked in every state. If notifications go missing when the app is closed on some Android phones, battery savers may be the cause; see why push notifications don't arrive in the background (written for Flutter, but the Android causes are the same).

Handling taps and opening the right screen

onNotification() gives you one callback for every event, with a type that says what happened:

  • received: a push arrived while the app was open. Nothing was tapped.
  • opened: the user tapped a push and the app came back from the background, or tapped a banner the SDK showed while the app was open.
  • initial: the user tapped a push and the app started from closed.
  • press: the user tapped a notification that Notifee displayed while the app was in the background.

So a tap can arrive as any of the last three. A common mistake is to only handle one of them and wonder why taps from a closed app do nothing. Send the screen and IDs in the push's data and route on them:

import { createNavigationContainerRef } from "@react-navigation/native";

export const navigationRef = createNavigationContainerRef();

NotifySphere.onNotification((notification, type) => {
  // type: "received" | "opened" | "initial" | "press"
  if (type === "received") return; // arrived while the app was open, not tapped

  const screen = notification.data?.screen;      // e.g. "Order"
  const id = notification.data?.orderId;         // any key you sent in data
  if (!screen) return;

  const go = () => navigationRef.navigate(screen, { id });
  // A cold start ("initial") can fire before the navigator is ready
  if (navigationRef.isReady()) go();
  else setTimeout(go, 500);
});
Enter fullscreen mode Exit fullscreen mode

Each callback receives the title, body, data, image and sound, so you can also show an in-app message instead of navigating.

What a push looks like

notifySphere sends mixed messages: a notification block so the operating system can show the push when your app isn't running, and a data block with tracking and routing information. When you compose a campaign in the dashboard you only fill in the title, text, image and your own key-value data; notifySphere builds the rest.

{
  "notification": { "title": "Your order is out", "body": "Arriving today by 6 PM" },
  "data": {
    "notification_id": "set-by-notifysphere",
    "screen": "Order",
    "orderId": "ORD123",
    "imageUrl": "https://example.com/van.jpg",
    "channelId": "orders",
    "sound": "default"
  },
  "android": { "priority": "high" }
}
Enter fullscreen mode Exit fullscreen mode

Every value in data is a string. If you need a number or an object in the app, convert it there.

Images, sounds and Android channels

Images. When the app is open, the SDK shows the image as a large picture on Android and as an attachment on iOS. In the background, Android shows the image from the notification block by itself. iOS only shows images in the background if your app has a Notification Service Extension that downloads the image before display; the React Native SDK doesn't add one for you.

Channels. Android 8 and later put every notification in a channel, and the user can mute each channel separately. Channels you configure in notifySphere (name, importance, sound, vibration, light and lock-screen visibility) are sent to the device when it registers, created by the SDK, and cached so they exist even before the app opens next time. A push with channelId in its data uses that channel.

Custom sounds. Put the sound file in android/app/src/main/res/raw/ on Android and in the app bundle on iOS, and refer to it by name without the extension. One Android rule catches everyone: once a channel exists, its sound can't be changed. If you change a sound, use a new channel ID.

Tags and segments

Tags are key-value pairs on the user, such as plan, city, language or the date of the last order. In the dashboard you build segments from them (users in Pune on the free plan, users who haven't ordered in 30 days) and send campaigns or start Automation flows for each segment. Pass tags in initialize(), or update them later without re-registering:

await NotifySphere.updateTags({
  applicationUserId: user.id,
  type: PUSH_TYPE,
  tags: { city: "Pune", lastOrderAt: new Date().toISOString() },
});
Enter fullscreen mode Exit fullscreen mode

Use strings for values, and use the same key names on Android and iOS so one segment covers both.

Conversions and campaign attribution

Opens are only half the story. To know whether a push, a WhatsApp message or a TrackerLink led to a purchase, send the conversion as an event:

// After a purchase, booking or signup inside the app
await NotifySphere.postEvent({
  event: "purchase",
  data: { revenue: 2500, order_id: "ORD123" },
});
Enter fullscreen mode Exit fullscreen mode

Events show up in attribution campaigns and funnels, and you can use them as triggers or exit rules in Automation. Installs are credited too. On Android, when someone installs from a TrackerLink, the SDK reads the Play install referrer on first launch and adds the click ID and campaign to the user's tags, so the match is exact. On iOS there is no install referrer, so notifySphere matches the first app open to a recent click from the same IP address or device within the attribution window (7 days by default), which is less precise. Deferred deep linking explained covers how both work.

Sign-out and account switching

A push token belongs to the device, not the user. If someone signs out and another person signs in on the same phone, the first user's notifications can keep arriving unless you detach the device. destroy() only removes local listeners. On sign-out, call logout(): it unsubscribes the device on the server, deletes the FCM token and clears the cached user data. Then call initialize() with the new user's ID and tags when they sign in.

// On sign-out: detach this device from the user (SDK 1.3.0+)
async function onSignOut() {
  await NotifySphere.logout(); // never throws
  // ...then clear your own session
}

// On sign-in: initialize() again with the new user's ID and tags
Enter fullscreen mode Exit fullscreen mode

Test it on one phone, because foreground, background and closed-app checks won't catch this:

  1. Sign in as user A and send a push to A. It should arrive.
  2. Sign out, then send to A again. Nothing should arrive on this phone.
  3. Sign in as user B. A push to B should arrive; a push to A still shouldn't.
  4. Check that B's tags and segments carry nothing over from A.

Thanks to Dave Kurian, who suggested this test in the comments of this guide on DEV.

Step 6: Test on real devices

Run a release-like build on a physical Android phone and a physical iPhone, accept the permission, then send a test push from the dashboard. Go through this list on both:

  1. App open: a banner appears and onNotification() fires with received.
  2. App in the background: the notification appears; tapping it opens the right screen.
  3. App swiped away: the notification appears; tapping it starts the app on the right screen.
  4. A push with an image, and one on a custom channel, look the way you expect.
  5. Delivered and opened counts move in the campaign report.
  6. The sign-out test above passes.

Turn on debug: true and watch adb logcat | grep NotifySphere on Android. In a closed app the SDK logs a line when the background handler fires. If that line never appears, the handler isn't registered in time.

dashboard.notifysphere.net

notifySphere dashboard with active subscriptions, app subscribers, monthly active users, and push sends

Once devices register, subscriptions, monthly active users, and push sends show up on the dashboard.

Troubleshooting

Symptom Usual cause and fix
Nothing arrives on iOS You're on the Simulator, the Push Notifications capability is missing, or no APNs key is uploaded to Firebase.
Nothing arrives on Android 13+ The user denied the notification permission, or the prompt never appeared because POST_NOTIFICATIONS is missing from the manifest.
Works when open, not when closed setBackgroundHandler() runs too late, usually inside a promise in index.js, or the appId isn't passed to it.
Android shows a grey or white square The small icon isn't a white shape on a transparent background, or the default_notification_icon entry is missing.
Sound doesn't change after you edit a channel Android channels can't be changed after creation. Use a new channel ID, or reinstall the app while testing.
401 from the API The apiKey is missing in initialize() or setBackgroundHandler().
Build fails on AsyncStorage Use @react-native-async-storage/async-storage@^1.23.1. Version 2 needs a Maven repository most projects don't have.
A signed-out user still gets pushes You called destroy() instead of logout() on sign-out.

For errors your server gets back from FCM or APNs, see the push notification error guides, including SENDER_ID_MISMATCH and BadDeviceToken. Pushes arriving late rather than not at all? Read why push notifications arrive late.

Frequently asked questions

Do I need Firebase for React Native push notifications?

On Android, yes: Firebase Cloud Messaging is how Google delivers pushes to apps. On iOS, Apple Push Notification service does the delivery, but using Firebase on both platforms means one token format and one sending API. The notifySphere React Native SDK uses @react-native-firebase/messaging on both.

Does this work with Expo?

The SDK relies on native modules (Firebase, Notifee, install referrer), so it does not run in Expo Go. It needs a bare React Native project, or an Expo project with a development build that includes those native modules.

Why don't notifications show when my React Native app is open?

Firebase doesn't display pushes while the app is in the foreground. The notifySphere SDK draws them itself with Notifee, so they appear as normal banners. On iOS, the willPresentNotification method in AppDelegate also has to return the alert, sound and badge options.

How do I open a specific screen when the user taps a notification?

Put the screen name and any IDs in the push's data, then handle the opened, initial and press types in onNotification() and navigate with a navigation ref. The initial type fires on a cold start, so wait until the navigator is ready.

When does the notification permission prompt appear?

When you call initialize(). On Android 13 and later the SDK asks for POST_NOTIFICATIONS and gives up after 10 seconds without an answer. On iOS it asks through Firebase and treats provisional (quiet) permission as granted. Older Android versions don't need a prompt.

How do I know a push was delivered?

The SDK sends a delivery receipt when a push arrives in the foreground, and when a data-only push arrives in the background or while the app is closed. Taps are tracked in every state. Delivery and open counts show up in the campaign report.

Can I track which campaign brought an install?

Yes. On Android the SDK reads the Play install referrer on first launch and saves the TrackerLink click and campaign in the user's tags, so the install and later events are credited exactly. On iOS, notifySphere matches the first app open to a recent click by IP address or device, which is less precise.

Next steps

The React Native SDK reference lists every method and option. Building in Flutter as well? The Flutter push notifications guide covers the same ground. Comparing providers? Read notifySphere vs OneSignal and other OneSignal alternatives.


Originally published on the notifySphere blog. notifySphere is push notifications, WhatsApp Business API, deep linking, and in-app support in one SDK — free up to 1,000 users.

Top comments (10)

Collapse
 
davekurian profile image
Dave Kurian •

One extra case worth adding to the matrix: switch accounts on the same device. Since the sample registers an applicationUserId and tags, sign out/in as two test users and verify the device identity and tags are reassigned or cleared before sending to the second account. Foreground/background/closed-state checks won't catch a stale account mapping.

Collapse
 
notifysphere profile image
Notify sphere •

Great catch, Dave, and thanks for reading closely. You're right that the foreground/background/closed checks won't catch a stale account mapping.

Right now the way to handle it is to call updateTags (or initialize) with the new applicationUserId right after the second user signs in, so the device token gets re-mapped, and to clear any user-specific tags on sign-out. A proper logout() that detaches the device on the server is a cleaner fix, so we're adding it to the SDK and I'll update this guide with an account-switch test case.

Collapse
 
davekurian profile image
Dave Kurian •

That logout() path sounds like the clean fix if it detaches the old token server-side. For the account-switch case, would you test that the first user stops receiving pushes and the second starts after sign-in?

Thread Thread
 
notifysphere profile image
Notify sphere •

Yes, that's exactly the test. The way I'd run it on one device:

  1. Sign in as User A, send a push to A. It should arrive.
  2. Sign out (logout). Send to A again. Nothing should arrive on this device.
  3. Sign in as User B. Send to B, it should arrive. Send to A, it still shouldn't.
  4. Check that B's tags/segments don't include anything left over from A.

Step 2 is the one most setups miss, because the token stays registered even after the app forgets the user. I'll add this as its own section in the guide once logout() ships. Thanks for pushing on it!

Thread Thread
 
davekurian profile image
Dave Kurian •

That catches the important failure mode: the app can clear its local session while the backend still routes to the old token. The User A → logout → User B sequence, plus checking that B carries no leftover tags from A, makes the account-switch test concrete. Thanks for planning to add it as a separate section.

Thread Thread
 
notifysphere profile image
Notify sphere •

Quick update: logout() is now live in react-native-notify-sphere 1.3.0, and the guide has a new "Account switching" section with the A -> logout -> B test: notifysphere.net/blog/react-native...

Thanks again for the push, Dave.

Thread Thread
 
davekurian profile image
Dave Kurian •

The new account-switch section makes that edge case concrete. Does logout() clear user-specific tags as well as detach the device, or should apps clear those separately?

Thread Thread
 
notifysphere profile image
Notify sphere •

Good question. logout() unsubscribes this device on the server, deletes the FCM token and clears the SDK's cached user state. It doesn't make a separate call to clear tags, so don't assume they're gone: after signing in as the second user, pass that user's own tags to initialize() and run the last step of the test to confirm nothing carried over from the first account.

Thread Thread
 
davekurian profile image
Dave Kurian •

That distinction helps. For the account-switch test, I'd check the second user's tags after reinitializing and confirm none of the first user's values carry over. Good to know logout handles the token and cached state, while tag cleanup stays with the app.

Thread Thread
 
notifysphere profile image
Notify sphere •

I checked our backend code to be sure. The logout endpoint marks the device's subscription as unsubscribed and closes the session, and it doesn't touch tags. So yes, tag cleanup stays with the app. Your check is the right one, and I've added the note to the guide.

⌂ 换网址