Push Notifications End to End in React Native: Tokens, FCM and Deep Links
Build push notifications end to end in React Native: permissions, FCM tokens, a Node.js backend, deep links, token refresh and troubleshooting.

Push notifications look simple: you call an API with a title and a message. In a React Native app, though, a push passes through permissions, device tokens, a backend, Firebase Cloud Messaging (FCM) and a deep link, and any one of those stages can fail on its own. When "the send API returned 200" is all you know, you have no idea which stage broke.
This guide walks you through the full pipeline on Android and iOS. You request permission, register for push, store the token on a Node.js backend, send through the Firebase Admin SDK, handle the message in every app state and open the right screen when the user taps.
Quick Answer
A reliable push system does four jobs. It gets a current FCM token from the app, keeps that token fresh on your backend, sends messages only from a trusted server with the Firebase Admin SDK, and handles the notification and its deep link on the device. FCM is the single delivery layer for both platforms, and on Apple devices it hands messages to APNs for you. Treat the token as an installation address that can change at any time, never as a user ID.
What Are Push Notifications?
A push notification is a message that a remote server delivers to an app installation, even when the app is not running. Three pieces make that work:
- Permission: whether the OS may show notifications to the user.
- A push token: a routing address for one app installation, which FCM uses to deliver a message.
- A delivery service: FCM for Android and APNs for Apple devices (FCM talks to APNs for you).
Permission, registration and the token are three independent things. A device can hold a valid token while the user has never allowed a visible notification, so you always check permission and never assume it.
The Problem
Delivery is a pipeline, and a push can break at any stage:
- The user denied notification permission, so nothing can be shown.
- Push registration never completed, so the device has no token.
- The current token never reached the backend, so you send to a stale value.
- The backend sent to an invalid or expired registration.
- FCM rejected the request because of a bad token or malformed payload.
- The device received the message, but the app handled it incorrectly.
- The notification appeared, but the tap did not navigate anywhere.
Neither platform promises more than best effort. Apple states that remote notification delivery is not guaranteed, and Firebase documents FCM as a pipeline across your server, the FCM backend, the platform transport and the client app. You debug a lifecycle, not a function call.
The Solution
Use FCM as the single backend interface so one Node.js server targets both platforms. The flow is always the same:
React Native app -> permission + register -> FCM token
-> Node.js API (store token, send push)
-> FCM -> Android | APNs -> iPhone/iPad
-> device -> OS notification -> tap -> deep link -> target screen
The table below shows where the two platforms differ.
| Area | Android | iOS |
|---|---|---|
| Platform transport | FCM | APNs (via FCM) |
| Backend interface | Firebase Admin SDK | Firebase Admin SDK |
| Permission model | Runtime permission on Android 13+ | User Notifications authorization |
| Notification channels | Required on Android 8+ | Not applicable |
| Deep linking | App Links and intents | Universal Links and URL handling |
Before you start, you need a React Native app, a Firebase project with an Android app and an iOS app registered (with google-services.json and GoogleService-Info.plist in place) and @react-native-firebase/messaging installed. On iOS you also need an Apple Developer membership, the Push Notifications capability and an APNs key uploaded to Firebase.
Step 1: Request notification permission
Call requestPermission() and treat both authorized and provisional as a yes:
import messaging from '@react-native-firebase/messaging';
export async function requestNotificationPermission(): Promise<boolean> {
const authStatus = await messaging().requestPermission();
return (
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL
);
}On Android 13 (API 33) and later, you also declare the runtime permission in AndroidManifest.xml. Fresh installs start with notifications off until the user grants it:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />On Android 8 (API 26) and later, every notification needs a channel. Create it once, here with Notifee. The user controls its behavior afterwards:
import notifee, { AndroidImportance } from '@notifee/react-native';
await notifee.createChannel({
id: 'orders',
name: 'Order updates',
importance: AndroidImportance.HIGH,
});Don't ask for permission cold. Ask right after the user does something that makes notifications obviously useful, such as placing an order. The iOS system prompt appears only once and a denial is hard to recover from, so treat the ask as a product decision. On iOS you can also use provisional authorization to deliver quiet trial notifications before you ask outright.

Step 2: Register for push and get the FCM token
Register the device and read the current token. On iOS, the device must be registered with APNs first:
import messaging from '@react-native-firebase/messaging';
export async function getCurrentPushToken(): Promise<string> {
await messaging().registerDeviceForRemoteMessages(); // iOS: register with APNs
return messaging().getToken();
}Underneath, the iOS FCM token is built on an APNs device token that your app delegate receives, and Firebase maps one to the other for you. Apple states that the APNs device token can change, and the same rule applies to FCM tokens. Treat the result as the current token, never a permanent one.

Step 3: Send the token to your backend
Expose one endpoint that the app calls whenever it has a current token. Tie the record to the authenticated user from the session, not to a user ID in the request body:
{
"platform": "android",
"installationId": "abc123",
"token": "token-value",
"appVersion": "2.4.0"
}A single table is enough to start. The last_seen_at column lets you expire tokens that have gone quiet:
push_registrations
id | user_id | platform | installation_id | token
| app_version | last_seen_at | created_at | updated_atDon't POST the token on every launch. Compare it with the last synced value, post only when it changed and keep last_seen_at current. Run this on startup and after login, and also on token-refresh events (Step 7).
Step 4: Build a Node.js push backend
The backend has two jobs: store registrations and send messages. Use the Firebase Admin SDK, which is built for trusted servers and holds credentials that must never reach a client:
npm install express firebase-admin dotenv// firebase.js
import admin from 'firebase-admin';
import 'dotenv/config';
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
}),
});
export const messaging = admin.messaging();The three credentials come from a service-account key. In the Firebase console, open Project settings, select the Service accounts tab and click Generate new private key. Copy the fields from the downloaded JSON into environment variables and keep the file itself out of your repository.

Resolve the target token from your own records and never accept an arbitrary token from a client. A message carries a notification block, which the OS displays, and a data block, which your app reads for routing:
import express from 'express';
import { messaging } from './firebase.js';
import { getRegistrationById } from './db.js';
import { requireAuth } from './auth.js';
export const router = express.Router();
router.post('/api/notifications/send', requireAuth, async (req, res) => {
const { registrationId, title, body } = req.body;
const reg = await getRegistrationById(registrationId);
if (!reg) return res.status(404).json({ error: 'registration not found' });
try {
const id = await messaging.send({
token: reg.token,
notification: { title, body }, // user-visible
data: { type: 'order', route: '/orders/12345' }, // app routing
});
res.json({ id });
} catch (err) {
res.status(502).json({ error: err.code }); // handle invalid tokens in Step 7
}
});
Once single-device sends work, FCM offers more targeting: multicast sends to an array of tokens, topics reach every device subscribed to a name, and conditions combine topics. Start with one registration and add fan-out only when the product needs it.
A 200 from your send call confirms only the first of six separate events: backend accepted, FCM accepted, device received, notification displayed, user tapped and deep link succeeded. Build your analytics and troubleshooting around these stages, not a single success flag.
Step 5: Handle incoming notifications
Behavior depends on the app state, so wire up four entry points. Keep the background handler light, because there is no UI to navigate yet:
import messaging from '@react-native-firebase/messaging';
// Foreground: present your own local notification.
messaging().onMessage(async msg => showLocalNotification(msg));
// Background and terminated (register at app entry, for example index.js).
messaging().setBackgroundMessageHandler(async msg => handleDataOnly(msg.data));
// Tapped while the app is in the background.
messaging().onNotificationOpenedApp(msg => navigateFromData(msg.data));
// Tapped from a cold start.
messaging().getInitialNotification().then(initial => {
if (initial) navigateFromData(initial.data);
});| App state | Message arrives | App handling | System notification |
|---|---|---|---|
| Foreground | Yes | Your onMessage handler runs | Only if you present one yourself |
| Background | Yes | Depends on payload and platform | A notification payload is often shown by the OS |
| Terminated | Platform-dependent | Background handler or launch data | The system may display it |
Terminated-state behavior varies by platform and by how you send the message, so verify it on real Android and iOS devices against the current FCM docs.
Step 6: Handle taps and deep links
A notification should land the user on the exact entity it refers to, not the home screen. Send a stable, semantic payload, a type plus an entity ID, instead of brittle UI details. The app then maps the entity to the right screen even after the UI changes:
{
"data": {
"type": "order",
"entityId": "12345",
"route": "/orders/12345"
}
}Treat the incoming link as untrusted input. Validate the route and ID, require authentication for protected screens and fall back cleanly when the resource is gone:
function navigateFromData(data?: { route?: string }) {
if (!data?.route || !isKnownRoute(data.route)) return goToFallback();
if (isProtected(data.route) && !isAuthenticated()) return goToLogin(data.route);
navigation.navigate(...resolveRoute(data.route)); // for example /orders/12345
}To let the same HTTPS URL open your app, set up Android App Links with an intent filter for your verified domain. For iOS, enable Associated Domains and host an apple-app-site-association file. React Native then receives the URL through the Linking API.

Step 7: Keep tokens fresh and remove stale ones
Tokens change after a reinstall, a refresh or a long period of inactivity. Subscribe to refresh events and sync the new value:
messaging().onTokenRefresh(token => syncTokenWithBackend(token));On the backend, treat the error code messaging/registration-token-not-registered as a sign that the registration is dead. Disable or delete it so you stop sending to it.
Step 8: Test the full flow
Check the happy path (permission, token, sync, send, delivery, tap, correct screen) on both platforms. Then check the failure paths: permission denied, notifications switched off in settings, a changed token, logout and login, uninstall and reinstall, an invalid token, a killed app, no network, a malformed deep link and a target entity that no longer exists.
Common errors
- Notification tap does nothing: you are missing open-event handling. Check
onNotificationOpenedApp,getInitialNotificationand yourdataparsing. - Android notification never appears: the channel or permission is wrong. Verify the channel exists and
POST_NOTIFICATIONSis granted on Android 13+. - Foreground message shows nothing: this is expected. In the foreground you present a local notification yourself.
- Send is rejected with UNREGISTERED: the token is invalid or stale. Read the FCM error code and remove the registration.
- iOS is registered but nothing arrives: check the Push Notifications capability, the APNs key in Firebase, signing and provisioning, and the dev versus production environment.
Trade-offs
This setup is not free. FCM ties your delivery path to Google, and you now own a token table that needs pruning, refresh handling and cleanup on logout. Delivery stays best effort, so you can't use push to carry state that must arrive. Topics and multicast are efficient for fan-out, but they give you no per-user authorization, and a wrong topic name reaches the wrong audience.
Notifee and the native channel setup add dependencies and platform code, and terminated-state behavior differs enough between iOS and Android that you must test on real devices.
This approach is the wrong choice in a few cases. If a message must be received, such as a security code or a legal notice, use SMS or email, and use push only as a nudge. If you build on Expo's managed workflow and want less native setup, Expo's own push service may fit better. If users are mostly on the web, you want web push instead.
Keep these practices in place wherever you do use it:
- Never send from the client. The Admin SDK credentials and APNs keys belong on the backend only.
- Authorize every send request server-side and resolve the token from your database.
- Keep payloads minimal, because they can appear on lock screens.
- Route the user to the screen, then fetch authoritative data from your backend.
Conclusion
On Monday morning, open your app and check each stage on its own: log whether permission was granted, whether the token reached your backend, what FCM returned for each send and whether the tap opened the right screen. Add onTokenRefresh syncing, delete registrations on UNREGISTERED errors and validate every deep-link payload before you navigate. Then run the failure cases from Step 8 on a real Android device and a real iPhone before you ship.
Need help shipping push notifications in your React Native app? Contact us
Sources

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.
