AboutBlogContact
Mobile DevelopmentApril 30, 2026 8 min read 6

React Native Push Notifications in 2026: Complete Guide (Expo + Firebase)

AunimedaAunimeda
📋 Table of Contents

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

  1. Create a project at console.firebase.google.com
  2. Add your iOS and Android apps
  3. Download google-services.json (Android) and GoogleService-Info.plist (iOS)
  4. 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

Read Also

Flutter vs React Native in 2026: An Engineer's Honest Comparisonaunimeda
Mobile Development

Flutter vs React Native in 2026: An Engineer's Honest Comparison

Flutter and React Native both ship to iOS and Android from a single codebase. But they make radically different bets. Here's the concrete trade-offs — performance benchmarks, ecosystem, hiring market, and which one fits which product.

Apple Intelligence & On-Device AI: What Mobile App Developers Need to Know in 2026aunimeda
Mobile Development

Apple Intelligence & On-Device AI: What Mobile App Developers Need to Know in 2026

On-device AI is no longer a lab experiment. Apple Intelligence and Gemini Nano are running directly on users' phones. Here's how to integrate these capabilities into your iOS and Android apps-without sending data to the cloud.

2GIS Maps Flutter Integration Guide: Maps for CIS Apps (2026)aunimeda
Mobile Development

2GIS Maps Flutter Integration Guide: Maps for CIS Apps (2026)

How to integrate 2GIS Maps SDK into your Flutter app. Map display, markers, routing, search, and geolocation - with full code examples for iOS and Android.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles