If you searched for "React Native push notifications" in 2026 and landed on a 2022 tutorial, half the code in it is broken. The legacy FCM endpoint was sunset in June 2024, Expo Go can't receive push on Android anymore, and Android 13+ needs a runtime permission your old guide doesn't mention.
This is a working 2026 guide for the three paths developers actually use:
-
Expo +
expo-notifications— easiest, recommended for most teams -
Bare React Native +
@react-native-firebase/messaging— full native control - Hybrid with Notifee — when you need rich UI
Code is copy-paste ready. Let's go.
What changed and why your old code is broken
-
FCM legacy API is dead.
https://fcm.googleapis.com/fcm/sendreturns 404. New endpoint isv1/projects/{id}/messages:sendwith OAuth 2.0. - Expo Go can't receive remote push since SDK 53. You need a development build.
-
Android 13+ requires
POST_NOTIFICATIONSat runtime. Forget it and your notifications silently never appear. -
iOS 15+ added interruption levels (
passive,active,time-sensitive,critical) that change how notifications break through Focus modes.
Path 1: Expo + expo-notifications
Install + dev build
npx expo install expo-notifications expo-device expo-constants
eas build --profile development --platform ios
You cannot test push in Expo Go anymore — accept the dev build cost up front.
Request permission and get the token
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
export async function registerForPushNotificationsAsync() {
if (!Device.isDevice) throw new Error('Physical device required');
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
});
}
const { status: existing } = await Notifications.getPermissionsAsync();
let final = existing;
if (existing !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
final = status;
}
if (final !== 'granted') throw new Error('Permission denied');
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
return (await Notifications.getExpoPushTokenAsync({ projectId })).data;
}
Send from your server
import { Expo } from 'expo-server-sdk';
const expo = new Expo();
export async function sendPush(tokens: string[], title: string, body: string) {
const messages = tokens
.filter(Expo.isExpoPushToken)
.map((to) => ({ to, sound: 'default', title, body, data: { url: '/inbox/42' } }));
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
await expo.sendPushNotificationsAsync(chunk);
}
}
chunkPushNotifications is required — Expo caps batches at 100.
Path 2: Bare RN + FCM HTTP v1
npm install @react-native-firebase/app @react-native-firebase/messaging
cd ios && pod install
Drop google-services.json into android/app/ and GoogleService-Info.plist into iOS. Enable Push Notifications + Background Modes in Xcode.
Permission + token
import messaging from '@react-native-firebase/messaging';
import { Platform, PermissionsAndroid } from 'react-native';
export async function registerForPushNotifications() {
if (Platform.OS === 'android' && Platform.Version >= 33) {
await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
);
}
const status = await messaging().requestPermission();
if (status === messaging.AuthorizationStatus.DENIED) {
throw new Error('Permission denied');
}
return messaging().getToken();
}
Server send with FCM HTTP v1
The 2026 endpoint. Authenticate with a service account JSON, not a server key.
import { GoogleAuth } from 'google-auth-library';
async function getAccessToken() {
const auth = new GoogleAuth({
keyFile: 'service-account.json',
scopes: ['https://www.googleapis.com/auth/firebase.messaging'],
});
const client = await auth.getClient();
const { token } = await client.getAccessToken();
return token;
}
export async function sendFcmV1(token: string, title: string, body: string) {
const accessToken = await getAccessToken();
const projectId = process.env.FIREBASE_PROJECT_ID;
const res = await fetch(
`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
token,
notification: { title, body },
data: { url: '/inbox/42' },
android: { priority: 'HIGH', notification: { channel_id: 'default' } },
apns: { payload: { aps: { sound: 'default', 'interruption-level': 'time-sensitive' } } },
},
}),
},
);
return res.json();
}
Two big changes from the legacy API:
- Platform overrides live in
androidandapnsblocks - OAuth token rotates ~hourly — cache it
Path 3: Notifee for rich UI
Neither expo-notifications nor RNFirebase gives you full control over how a notification looks. Notifee does. Pair it with your delivery layer.
import notifee, { AndroidStyle, AndroidImportance } from '@notifee/react-native';
import messaging from '@react-native-firebase/messaging';
messaging().setBackgroundMessageHandler(async (msg) => {
const channelId = await notifee.createChannel({
id: 'chat',
name: 'Chat',
importance: AndroidImportance.HIGH,
});
await notifee.displayNotification({
title: msg.data?.title,
body: msg.data?.body,
android: {
channelId,
style: {
type: AndroidStyle.MESSAGING,
person: { name: msg.data?.sender, icon: msg.data?.avatar },
messages: [{ text: msg.data?.body, timestamp: Date.now() }],
},
actions: [
{ title: 'Reply', pressAction: { id: 'reply' }, input: true },
{ title: 'Mark read', pressAction: { id: 'mark-read' } },
],
},
});
});
This gets you Messenger-style chat notifications, ongoing progress bars, and full-screen incoming-call screens.
The thing every tutorial gets wrong: cold start
When a user taps a notification with your app killed, your handlers are not mounted. You have to check the initial payload synchronously on first render.
Expo:
const lastResponse = Notifications.useLastNotificationResponse();
useEffect(() => {
const url = lastResponse?.notification.request.content.data?.url;
if (url) router.push(url);
}, [lastResponse]);
RNFirebase:
useEffect(() => {
messaging().getInitialNotification().then((msg) => {
if (msg?.data?.url) router.push(msg.data.url);
});
}, []);
Always test by force-quitting the app, killing it from recents, and tapping the notification cold. That's the path that breaks in production.
Token lifecycle (the 90% nobody writes about)
Working getToken() is 10% of the job. The rest:
- Upsert on every cold start. Treat the token your app sends as the source of truth.
-
Soft-delete on
UNREGISTERED/NotRegistered. Stale tokens silently kill delivery rate. -
Model
(user_id, device_id, token, platform, last_seen_at)rows. Notusers.push_token. One user has N devices. - Retry with exponential backoff. 5xx and 429 are real. 250ms -> 500ms -> 1s -> 2s -> 4s with a circuit breaker.
Quick decision matrix
| You want… | Use… |
|---|---|
| Fastest setup | Expo + expo-notifications
|
| Full native control | Bare RN + @react-native-firebase/messaging
|
| Rich layouts, action buttons, ongoing notifications | + Notifee on top |
| Hosted dashboard, A/B testing | OneSignal |
| Lifecycle marketing | Customer.io / Braze |
That's the whole tree.
If you want the long-form version with the production server architecture, decision matrix, and PAA section, the canonical post is on the RapidNative blog.
What did your last broken-push-notifications debugging session look like? Drop it in the comments — I'm collecting failure modes for a follow-up.
Top comments (0)