This guide was originally published in 2021 and has been rewritten from scratch for 2026. Almost everything in the old version is now obsolete: the react-native-push-notification library is archived, the FCM legacy HTTP API it relied on was shut down by Google, and both Android and iOS have changed how notification permissions work. Here is how you actually ship push notifications in a React Native app today.
The 2026 stack, in one paragraph
There are two mainstream paths. If you are on Expo (which most new React Native projects are), use expo-notifications with Expo’s push service, and you never touch APNs certificates or FCM server code directly. If you are on a bare React Native project, or you need full control over notification appearance and behavior, use @react-native-firebase/messaging for delivery plus Notifee for display. On the server side, everything now goes through the FCM HTTP v1 API, authenticated with a Google service account, not the old server key.
Path 1: Expo Notifications
Install the packages and register for a push token:
npx expo install expo-notifications expo-device expo-constants
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
async function registerForPush() {
if (!Device.isDevice) return null; // push needs real hardware
const { status: existing } = await Notifications.getPermissionsAsync();
let status = existing;
if (existing !== 'granted') {
const res = await Notifications.requestPermissionsAsync({
ios: { allowAlert: true, allowBadge: true, allowSound: true },
});
status = res.status;
}
if (status !== 'granted') return null;
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = await Notifications.getExpoPushTokenAsync({ projectId });
return token.data; // "ExponentPushToken[...]"
}
Send that token to your backend and store it against the user. To deliver a notification, POST to Expo’s push endpoint:
POST https://exp.host/--/api/v2/push/send
Content-Type: application/json
{
"to": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
"title": "New message",
"body": "Anna: are we still on for 3pm?",
"data": { "chatId": "room-42" },
"channelId": "messages"
}
Expo translates this into APNs and FCM calls for you. You still need an google-services.json in the project and FCM credentials uploaded to your Expo project for Android, and Apple push keys handled through EAS for iOS, but EAS credentials management does most of that during eas build.
One important change: remote push no longer works in the Expo Go sandbox app on recent SDKs. You need a development build (npx expo run:ios or an EAS development build) to test real notifications. Local notifications still work in Expo Go, which trips people up because half the flow appears to work.
Path 2: Bare workflow with Firebase Messaging and Notifee
In a bare project the division of labor matters: @react-native-firebase/messaging gets the message to the device, and Notifee draws it. FCM will render simple “notification” payloads on its own, but the moment you want grouped chat messages, action buttons, custom sounds, or full control on Android, you send data-only payloads and display them yourself with Notifee.
import messaging from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from '@notifee/react-native';
// Get and watch the FCM token
const token = await messaging().getToken();
messaging().onTokenRefresh(t => sendTokenToBackend(t));
// Foreground messages: FCM does NOT display these, you do
messaging().onMessage(async remoteMessage => {
await notifee.displayNotification({
title: remoteMessage.data?.title,
body: remoteMessage.data?.body,
android: {
channelId: 'messages',
importance: AndroidImportance.HIGH,
pressAction: { id: 'default' },
},
});
});
// Background/quit-state messages: register OUTSIDE any component,
// typically in index.js
messaging().setBackgroundMessageHandler(async remoteMessage => {
// display with notifee, update badge, sync data, etc.
});
On Android you must create a notification channel before you can display anything, and users can silence individual channels, so name them by purpose (“Messages”, “Promotions”) rather than shipping one generic channel:
await notifee.createChannel({
id: 'messages',
name: 'Messages',
importance: AndroidImportance.HIGH,
});
Permissions: what changed on both platforms
Android 13 introduced a runtime permission, POST_NOTIFICATIONS. Apps targeting API 33 and above get no notifications at all until the user grants it. Declare it in the manifest and request it at a sensible moment, not on first launch:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
import { PermissionsAndroid, Platform } from 'react-native';
if (Platform.OS === 'android' && Platform.Version >= 33) {
await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
);
}
On iOS, consider provisional authorization. It delivers notifications quietly to the Notification Center without an upfront permission dialog, and the user decides later whether to promote them to full alerts. It is a good fit when you have no strong onboarding moment to justify the prompt:
await messaging().requestPermission({ provisional: true });
// or with Notifee:
await notifee.requestPermission({ provisional: true });
Either way, the best-converting pattern is still a pre-permission screen in your own UI (“Get notified when someone replies”) followed by the system dialog only after the user opts in. You get one shot at the iOS system prompt; if it is denied, recovery means sending the user to Settings.
APNs setup essentials
For iOS you need three things: the Push Notifications capability enabled on your App ID and in Xcode, an APNs authentication key (a .p8 file, which works for all your apps and does not expire the way the old certificates did), and that key uploaded to your Firebase project settings if FCM is your bridge to APNs. Remember that alerts arrive through APNs even when you send via FCM, so a misconfigured key manifests as “Android works, iOS silently drops everything.” Also note APNs distinguishes sandbox and production environments; a debug build talks to the sandbox, a TestFlight or App Store build talks to production.
Sending from your server: FCM HTTP v1
The legacy endpoint (fcm.googleapis.com/fcm/send with a server key header) is gone. The v1 API authenticates with a short-lived OAuth token derived from a service account and scopes each request to your project:
POST https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send
Authorization: Bearer <oauth2-access-token>
Content-Type: application/json
{
"message": {
"token": "<device-fcm-token>",
"notification": { "title": "New message", "body": "Anna: see you at 3" },
"data": { "chatId": "room-42" },
"android": { "priority": "HIGH" },
"apns": { "payload": { "aps": { "sound": "default", "badge": 1 } } }
}
}
In practice you rarely hand-roll this: the Firebase Admin SDK for Node, Python, Go, or Java wraps token minting and retries. The structural difference from the legacy API is that platform-specific behavior lives in the android and apns blocks instead of a flat payload, which is what you want anyway once iOS and Android start diverging.
Deep linking from a notification tap
A notification that dumps the user on your home screen is a bug. Put a route or entity id in the data payload and handle three cases: app in foreground, app in background, and app cold-started by the tap.
// Expo
const sub = Notifications.addNotificationResponseReceivedListener(res => {
const { chatId } = res.notification.request.content.data;
router.push(`/chat/${chatId}`);
});
// React Native Firebase
messaging().onNotificationOpenedApp(msg => {
navigate('Chat', { id: msg.data.chatId });
});
const initial = await messaging().getInitialNotification();
if (initial) navigate('Chat', { id: initial.data.chatId });
The cold-start case is the one teams forget: your navigator may not be mounted yet when the initial notification is read, so stash the target route and navigate once navigation is ready.
Pitfalls that cost real debugging time
- Tokens rotate. FCM and Expo tokens are not permanent. They change on reinstall, restore from backup, and periodically on their own. Always listen for token refresh, re-upload on every app start, and delete tokens server-side when FCM returns an unregistered error, or you will slowly accumulate dead tokens and skewed delivery metrics.
- Data-only messages are throttled. Android’s Doze mode and iOS background limits mean silent, data-only pushes are delivered late or not at all when the app has been backgrounded a while. If the user must see it, send a visible notification. Reserve data-only for genuinely optional sync work, and on iOS remember
content-availabledelivery is best effort. - iOS simulator limits. The simulator cannot register with APNs, so remote push registration fails there. You can still simulate delivery locally with
xcrun simctl push booted com.your.bundle payload.jsonor by dragging an.apnsfile onto the simulator, which is enough to test rendering and tap handling, but end-to-end tests need a physical device. - Battery optimizers on OEM Android. Some manufacturers aggressively kill background processes, so delayed notifications on specific devices are often the OEM’s task killer, not your code.
- Foreground display. Both platforms suppress or hand off foreground notifications to your handler. If “notifications work except when the app is open,” you have not implemented the foreground path.
Where this matters most: chat
Messaging apps are the canonical push workload: high volume, per-conversation grouping, reply actions, badge counts, and mute rules per room. If chat is the reason you are reading this, be aware that most React Native chat SDKs handle push routing for you, including token registration, per-conversation notification payloads, and unread badge logic, which removes most of the server-side work described above. Rolling your own is a fine learning exercise, but for production chat it is usually the wrong place to spend your time.
FAQ
Do push notifications work in Expo Go?
Not anymore for remote push on recent SDKs. Local notifications still fire in Expo Go, but receiving real remote notifications requires a development build or a production build. Use npx expo run:android, npx expo run:ios, or an EAS development build to test.
Why are my Android notifications delayed?
Usually one of three things: you are sending data-only messages, which Doze mode defers; your message priority is normal instead of high; or the device manufacturer’s battery optimizer is restricting your app. Send visible notifications with high priority for anything time-sensitive, and test on the specific OEM devices your users report.
How do I test push on the iOS simulator?
The simulator cannot receive real APNs pushes, but you can inject a payload locally: save the JSON to a file and run xcrun simctl push booted <bundle-id> payload.json, or drag a .apns file onto the simulator window. That covers rendering and tap handling. For token registration and true end-to-end delivery, use a physical device.