DEV Community

Russel Dsouza
Russel Dsouza

Posted on

Push Notifications in React Native: The Complete 2026 Guide

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:

  1. Expo + expo-notifications — easiest, recommended for most teams
  2. Bare React Native + @react-native-firebase/messaging — full native control
  3. 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/send returns 404. New endpoint is v1/projects/{id}/messages:send with OAuth 2.0.
  • Expo Go can't receive remote push since SDK 53. You need a development build.
  • Android 13+ requires POST_NOTIFICATIONS at 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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Two big changes from the legacy API:

  • Platform overrides live in android and apns blocks
  • 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' } },
      ],
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

RNFirebase:

useEffect(() => {
  messaging().getInitialNotification().then((msg) => {
    if (msg?.data?.url) router.push(msg.data.url);
  });
}, []);
Enter fullscreen mode Exit fullscreen mode

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. Not users.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)