React Native Push Notifications in 2026: Complete Guide
Push notifications done right drive retention. Push notifications done wrong get your app deleted. The difference is targeting, timing, and technical implementation that works reliably across iOS, Android, and background states.
This guide covers the full implementation — expo-notifications for the client, Firebase Cloud Messaging (FCM) on the backend, deep linking from notification tap, and the analytics you need to measure impact.
Architecture Overview
Your Server
↓
Firebase Cloud Messaging (FCM)
↓
Apple APNs (iOS) / Google FCM (Android)
↓
User's Device
↓
Your App (foreground / background / killed)
FCM is the universal relay — you send one request to FCM, it handles delivery to both iOS (via APNs) and Android.
Step 1: Install Dependencies
# Expo managed workflow
npx expo install expo-notifications expo-device expo-constants
# Bare React Native
npm install @react-native-firebase/app @react-native-firebase/messaging
This guide uses Expo Notifications — it works in both managed and bare workflows and handles most of the APNs/FCM complexity.
Step 2: Configure Firebase
- Create a project at console.firebase.google.com
- Add your iOS and Android apps
- Download
google-services.json(Android) andGoogleService-Info.plist(iOS) - In Expo: add to
app.json:
{
"expo": {
"android": {
"googleServicesFile": "./google-services.json",
"package": "com.yourcompany.yourapp"
},
"ios": {
"googleServicesFile": "./GoogleService-Info.plist",
"bundleIdentifier": "com.yourcompany.yourapp",
"infoPlist": {
"UIBackgroundModes": ["fetch", "remote-notification"]
}
},
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#ffffff",
"defaultChannel": "default"
}
]
]
}
}
Step 3: Request Permission and Get Token
// hooks/usePushNotifications.ts
import { useEffect, useRef, useState } from 'react';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
// How to handle notifications when the app is in the foreground
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
async function registerForPushNotifications(): Promise<string | null> {
if (!Device.isDevice) {
console.warn('Push notifications require a physical device');
return null;
}
// Check existing permission
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
// Request if not determined
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
return null; // User declined
}
// Android: create notification channel
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C',
});
await Notifications.setNotificationChannelAsync('orders', {
name: 'Order Updates',
importance: Notifications.AndroidImportance.HIGH,
description: 'Order status and delivery updates',
});
}
// Get the Expo push token (wraps FCM token)
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = await Notifications.getExpoPushTokenAsync({ projectId });
return token.data; // e.g., "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]"
}
export function usePushNotifications() {
const [pushToken, setPushToken] = useState<string | null>(null);
const notificationListener = useRef<Notifications.Subscription>();
const responseListener = useRef<Notifications.Subscription>();
useEffect(() => {
registerForPushNotifications().then(setPushToken);
// Notification received while app is OPEN
notificationListener.current = Notifications.addNotificationReceivedListener(
(notification) => {
console.log('Notification received:', notification);
// Update badge, play sound, etc.
}
);
// User tapped the notification
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
const data = response.notification.request.content.data;
handleNotificationTap(data);
}
);
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
};
}, []);
return { pushToken };
}
Step 4: Save Token to Your Backend
// After getting the token, save it with the user's account
async function saveTokenToServer(userId: string, pushToken: string, platform: string) {
await fetch('https://api.yourapp.com/users/push-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await getAuthToken()}`,
},
body: JSON.stringify({
userId,
pushToken,
platform, // 'ios' or 'android'
deviceId: Device.osInternalBuildId, // for deduplication
}),
});
}
Backend schema:
CREATE TABLE push_tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token text NOT NULL UNIQUE,
platform text NOT NULL CHECK (platform IN ('ios', 'android')),
device_id text,
created_at timestamptz DEFAULT now(),
last_used timestamptz DEFAULT now()
);
CREATE INDEX idx_push_tokens_user ON push_tokens(user_id);
Step 5: Send Notifications from Your Backend
Using the Expo Push API (simplest — wraps FCM):
// backend/src/services/notifications.ts
import { Expo, ExpoPushMessage, ExpoPushTicket } from 'expo-server-sdk';
const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });
interface NotificationPayload {
title: string;
body: string;
data?: Record<string, unknown>;
channelId?: string; // Android notification channel
}
export async function sendPushNotification(
userIds: string[],
payload: NotificationPayload
): Promise<void> {
// Get all tokens for these users
const tokens = await db.query(
`SELECT token FROM push_tokens WHERE user_id = ANY($1)`,
[userIds]
);
const messages: ExpoPushMessage[] = tokens.rows
.filter(({ token }) => Expo.isExpoPushToken(token))
.map(({ token }) => ({
to: token,
title: payload.title,
body: payload.body,
data: payload.data ?? {},
channelId: payload.channelId ?? 'default',
sound: 'default',
badge: 1,
}));
if (messages.length === 0) return;
// Send in chunks (Expo limit: 100 per request)
const chunks = expo.chunkPushNotifications(messages);
const tickets: ExpoPushTicket[] = [];
for (const chunk of chunks) {
const ticketChunk = await expo.sendPushNotificationsAsync(chunk);
tickets.push(...ticketChunk);
}
// Handle errors
for (let i = 0; i < tickets.length; i++) {
const ticket = tickets[i];
if (ticket.status === 'error') {
if (ticket.details?.error === 'DeviceNotRegistered') {
// Remove stale token
await db.query(
`DELETE FROM push_tokens WHERE token = $1`,
[messages[i].to]
);
}
}
}
}
// Example usage:
await sendPushNotification(['user-123'], {
title: '📦 Order Shipped!',
body: 'Your order #1234 is on its way. Expected delivery: tomorrow.',
data: {
screen: 'OrderDetail',
orderId: '1234',
},
channelId: 'orders',
});
Step 6: Deep Linking from Notification Tap
When the user taps a notification, navigate them to the right screen:
// app/_layout.tsx
import { useEffect } from 'react';
import { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';
export default function RootLayout() {
const router = useRouter();
useEffect(() => {
// App opened FROM a notification (was killed/background)
Notifications.getLastNotificationResponseAsync().then((response) => {
if (response) {
handleNotificationNavigation(response.notification.request.content.data, router);
}
});
// App in background, user taps notification
const subscription = Notifications.addNotificationResponseReceivedListener(
(response) => {
handleNotificationNavigation(
response.notification.request.content.data,
router
);
}
);
return () => subscription.remove();
}, []);
return <Slot />;
}
function handleNotificationNavigation(data: Record<string, unknown>, router: any) {
switch (data.screen) {
case 'OrderDetail':
router.push(`/orders/${data.orderId}`);
break;
case 'Chat':
router.push(`/chats/${data.chatId}`);
break;
case 'Promo':
router.push(`/promotions/${data.promoId}`);
break;
default:
router.push('/');
}
}
Step 7: Background Notifications (Silent Push)
Silent pushes update data without showing a notification UI. Useful for syncing content, refreshing cache, or updating badge counts.
// Only works with @react-native-firebase/messaging on bare workflow
import messaging from '@react-native-firebase/messaging';
// Register background handler — runs even when app is killed
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
if (remoteMessage.data?.type === 'sync') {
await syncUserData();
}
if (remoteMessage.data?.type === 'badge_update') {
// Update badge from background
await Notifications.setBadgeCountAsync(
Number(remoteMessage.data.count)
);
}
});
Notification Scheduling (Local)
For reminders that don't require a server:
async function scheduleOrderReminder(orderId: string, deliveryDate: Date) {
// Remind 2 hours before estimated delivery
const triggerDate = new Date(deliveryDate.getTime() - 2 * 60 * 60 * 1000);
await Notifications.scheduleNotificationAsync({
content: {
title: '📦 Delivery Today!',
body: 'Your order is arriving in about 2 hours.',
data: { screen: 'OrderDetail', orderId },
},
trigger: {
date: triggerDate,
},
});
}
// Daily reminder (e.g., for habit apps)
await Notifications.scheduleNotificationAsync({
content: {
title: 'Don\'t forget your daily check-in!',
body: 'You\'re on a 5-day streak 🔥',
},
trigger: {
hour: 10,
minute: 0,
repeats: true,
},
});
// Cancel a scheduled notification
await Notifications.cancelScheduledNotificationAsync(notificationId);
Segmentation and Targeting
Don't blast everyone — it kills opt-in rates.
// backend: send to specific segments
async function sendSegmentedNotification(
segment: 'inactive_7d' | 'high_value' | 'cart_abandoners',
payload: NotificationPayload
) {
let userIds: string[];
switch (segment) {
case 'inactive_7d':
// Users who haven't opened the app in 7 days
const { rows } = await db.query(`
SELECT DISTINCT user_id FROM push_tokens pt
WHERE NOT EXISTS (
SELECT 1 FROM user_sessions s
WHERE s.user_id = pt.user_id
AND s.created_at > NOW() - INTERVAL '7 days'
)
`);
userIds = rows.map(r => r.user_id);
break;
case 'cart_abandoners':
const { rows: cartRows } = await db.query(`
SELECT DISTINCT user_id FROM carts
WHERE updated_at < NOW() - INTERVAL '1 hour'
AND updated_at > NOW() - INTERVAL '24 hours'
AND total_items > 0
`);
userIds = cartRows.map(r => r.user_id);
break;
}
await sendPushNotification(userIds, payload);
}
Analytics: What to Track
// Track notification events for performance measurement
async function trackNotificationEvent(
event: 'sent' | 'delivered' | 'opened' | 'dismissed',
notificationId: string,
userId: string
) {
await analytics.track({
event: `notification_${event}`,
properties: { notificationId, userId, timestamp: new Date() },
});
}
// In the notification response listener:
Notifications.addNotificationResponseReceivedListener((response) => {
const { notificationId, userId } = response.notification.request.content.data;
trackNotificationEvent('opened', notificationId, userId);
handleNotificationNavigation(response.notification.request.content.data, router);
});
Key metrics to watch:
- Opt-in rate (target: >60% for utility apps, >40% for content apps)
- Open rate by category (transactional: 30-50%, marketing: 5-15%)
- Opt-out rate (red flag if >2%/week — you're over-sending)
- Conversion rate (notification tap → purchase/action)
Common Mistakes
Asking for permission too early. The worst time to ask for push permission is on first app launch. The user has no context. Ask after they've experienced value — after the first order, after account creation, after meaningful action.
No opt-out control. Users who can't manage notification preferences opt out entirely. Build granular settings: "Order updates", "Promotions", "News" as separate toggles.
Sending at wrong times. Schedule notifications in the user's local timezone. A 10am notification in UTC lands at 2am in Kazakhstan. Use Intl.DateTimeFormat to get user timezone from device and store it.
Not handling token refresh. Tokens change when users reinstall or restore from backup. Firebase tokens also rotate. Re-upload the token on every app launch.
Aunimeda builds React Native and Flutter mobile applications with full push notification infrastructure — from implementation through to analytics and A/B testing.
Contact us to discuss your mobile app project. See also: Mobile App Development, Custom Software Development