Back to Blog

React Native Push Notifications: Expo Notifications, APNs/FCM, Deep Links, and Notification Center

Implement React Native push notifications with Expo Notifications: APNs and FCM setup, deep link handling, local notifications, notification center UI, and server-side push with Expo Push API.

Viprasol Tech Team
November 21, 2026
13 min read

Push notifications are the highest-engagement re-activation channel in mobile โ€” open rates of 20โ€“30% vs 2% for email. The engineering challenge is the plumbing: APNs certificates for iOS, FCM credentials for Android, token registration, permission flows, deep link routing, and the server-side infrastructure to send reliably at scale. Expo Notifications abstracts most of the platform complexity while keeping you in control of the UX.

This post covers the complete implementation: permission request flow, push token registration, server-side sending with the Expo Push API, deep link handling, and a notification center UI.

Architecture

App registers โ†’ Expo Push Token (device + app identifier)
      โ”‚
      โ–ผ
Server stores token โ†’ sends via Expo Push API
      โ”‚
      โ–ผ
Expo routes โ†’ APNs (iOS) or FCM (Android)
      โ”‚
      โ–ผ
Device receives notification
      โ”‚
      โ”œโ”€โ”€ App in foreground โ†’ handle programmatically
      โ”œโ”€โ”€ App in background โ†’ system tray, tap โ†’ deep link
      โ””โ”€โ”€ App killed โ†’ system tray, tap โ†’ app opens at deep link

1. Setup

npx expo install expo-notifications expo-device expo-constants
// app.json / app.config.ts additions
export default {
  expo: {
    plugins: [
      [
        'expo-notifications',
        {
          icon: './assets/notification-icon.png',
          color: '#2563EB',
          defaultChannel: 'default', // Android channel
          sounds: ['./assets/notification.wav'],
        },
      ],
    ],
    android: {
      googleServicesFile: './google-services.json', // FCM config
    },
    ios: {
      bundleIdentifier: 'com.viprasol.app',
      // APNs configured via EAS / Apple Developer Portal
    },
  },
};

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

2. Permission and Token Registration

// src/lib/notifications/registration.ts
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import * as Application from 'expo-application';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
import { api } from '../api-client';

// Configure how notifications appear when app is in foreground
Notifications.setNotificationHandler({
  handleNotification: async (notification) => ({
    shouldShowAlert: true,
    shouldPlaySound: notification.request.content.sound !== null,
    shouldSetBadge: false,
    shouldShowBanner: true,
    shouldShowList: true,
  }),
});

export type PushPermissionStatus = 'granted' | 'denied' | 'undetermined';

export async function requestPushPermissions(): Promise<PushPermissionStatus> {
  // Push notifications only work on physical devices
  if (!Device.isDevice) {
    console.warn('Push notifications require a physical device');
    return 'denied';
  }

  const { status: existingStatus } = await Notifications.getPermissionsAsync();

  if (existingStatus === 'granted') return 'granted';
  if (existingStatus === 'denied') return 'denied'; // Can't re-prompt on iOS

  const { status } = await Notifications.requestPermissionsAsync({
    ios: {
      allowAlert: true,
      allowBadge: true,
      allowSound: true,
      allowCriticalAlerts: false, // Don't request unless needed
    },
  });

  return status as PushPermissionStatus;
}

export async function registerPushToken(userId: string): Promise<string | null> {
  const status = await requestPushPermissions();
  if (status !== 'granted') return null;

  // Android: create notification channel
  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'Default',
      importance: Notifications.AndroidImportance.MAX,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#2563EB',
      sound: 'notification.wav',
    });

    await Notifications.setNotificationChannelAsync('marketing', {
      name: 'Tips & Updates',
      importance: Notifications.AndroidImportance.DEFAULT,
      description: 'Product updates, tips, and promotions',
    });
  }

  const projectId = Constants.expoConfig?.extra?.eas?.projectId;
  if (!projectId) throw new Error('EAS project ID not configured');

  const token = await Notifications.getExpoPushTokenAsync({ projectId });

  // Register token with backend
  await api.post('/notifications/token', {
    token: token.data,
    platform: Platform.OS,
    deviceId: Application.getAndroidId() ?? Application.applicationId,
  });

  return token.data;
}

3. Server-Side Push Sending

// src/services/notifications/push.service.ts
import { Expo, ExpoPushMessage, ExpoPushTicket } from 'expo-server-sdk';
import { db } from '../../lib/db';

const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });

interface SendNotificationInput {
  userIds: string[];
  title: string;
  body: string;
  data?: Record<string, unknown>;
  channelId?: string;
  badge?: number;
}

export async function sendPushNotifications(
  input: SendNotificationInput
): Promise<{ sent: number; failed: number }> {
  // Fetch all push tokens for target users
  const tokens = await db.pushToken.findMany({
    where: {
      userId: { in: input.userIds },
      isActive: true,
    },
    select: { id: true, token: true, platform: true },
  });

  if (tokens.length === 0) return { sent: 0, failed: 0 };

  // Build messages (validate token format)
  const messages: ExpoPushMessage[] = tokens
    .filter((t) => Expo.isExpoPushToken(t.token))
    .map((t) => ({
      to: t.token,
      title: input.title,
      body: input.body,
      data: input.data ?? {},
      channelId: input.channelId ?? 'default',
      badge: input.badge,
      sound: 'default',
      priority: 'high',
    }));

  // Send in chunks (Expo recommends max 100 per request)
  const chunks = expo.chunkPushNotifications(messages);
  const tickets: ExpoPushTicket[] = [];

  for (const chunk of chunks) {
    const chunkTickets = await expo.sendPushNotificationsAsync(chunk);
    tickets.push(...chunkTickets);
  }

  // Process tickets
  let sent = 0;
  let failed = 0;

  for (let i = 0; i < tickets.length; i++) {
    const ticket = tickets[i];
    const token = tokens[i];

    if (ticket.status === 'ok') {
      sent++;
      // Store receipt ID for delivery confirmation (check later)
      await db.pushTicket.create({
        data: {
          tokenId: token.id,
          receiptId: ticket.id,
          sentAt: new Date(),
        },
      });
    } else {
      failed++;
      console.error(`Push failed for token ${token.token}:`, ticket.details);

      // Handle invalid tokens (device uninstalled app)
      if (
        ticket.details?.error === 'DeviceNotRegistered' ||
        ticket.details?.error === 'InvalidCredentials'
      ) {
        await db.pushToken.update({
          where: { id: token.id },
          data: { isActive: false },
        });
      }
    }
  }

  return { sent, failed };
}

// Check delivery receipts (run hourly via cron)
export async function checkPushReceipts(): Promise<void> {
  const tickets = await db.pushTicket.findMany({
    where: {
      receiptChecked: false,
      sentAt: { lte: new Date(Date.now() - 15 * 60 * 1000) }, // At least 15 min old
    },
    select: { id: true, receiptId: true, tokenId: true },
    take: 100,
  });

  if (tickets.length === 0) return;

  const receiptIds = tickets.map((t) => t.receiptId);
  const receiptChunks = expo.chunkPushNotificationReceiptIds(receiptIds);

  for (const chunk of receiptChunks) {
    const receipts = await expo.getPushNotificationReceiptsAsync(chunk);

    for (const [receiptId, receipt] of Object.entries(receipts)) {
      const ticket = tickets.find((t) => t.receiptId === receiptId);
      if (!ticket) continue;

      if (receipt.status === 'error') {
        if (receipt.details?.error === 'DeviceNotRegistered') {
          await db.pushToken.update({
            where: { id: ticket.tokenId },
            data: { isActive: false },
          });
        }
      }

      await db.pushTicket.update({
        where: { id: ticket.id },
        data: { receiptChecked: true, deliveryStatus: receipt.status },
      });
    }
  }
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

4. Deep Link Handling

// src/lib/notifications/deep-links.ts
import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';
import { useEffect } from 'react';

export interface NotificationData {
  screen: string;
  params?: Record<string, string>;
  action?: string;
}

function handleNotificationNavigation(data: NotificationData) {
  switch (data.screen) {
    case 'order':
      router.push(`/orders/${data.params?.orderId}`);
      break;
    case 'message':
      router.push(`/messages/${data.params?.conversationId}`);
      break;
    case 'billing':
      router.push('/settings/billing');
      break;
    case 'feature-announcement':
      router.push(`/whats-new/${data.params?.featureId}`);
      break;
    default:
      router.push('/dashboard');
  }
}

// Hook: handle deep links from notifications
export function useNotificationDeepLinks() {
  useEffect(() => {
    // App was killed โ€” notification opened the app
    Notifications.getLastNotificationResponseAsync().then((response) => {
      if (response) {
        const data = response.notification.request.content.data as NotificationData;
        handleNotificationNavigation(data);
      }
    });

    // App was in background โ€” notification tapped
    const subscription = Notifications.addNotificationResponseReceivedListener(
      (response) => {
        const data = response.notification.request.content.data as NotificationData;
        handleNotificationNavigation(data);
      }
    );

    return () => subscription.remove();
  }, []);
}

5. In-App Notification Center

// src/components/NotificationCenter.tsx
'use client';

import * as Notifications from 'expo-notifications';
import { useEffect, useState } from 'react';
import { FlatList, View, Text, Pressable, RefreshControl } from 'react-native';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api-client';

interface AppNotification {
  id: string;
  title: string;
  body: string;
  data: Record<string, unknown>;
  isRead: boolean;
  createdAt: string;
}

export function NotificationCenter() {
  const queryClient = useQueryClient();
  const [unreadCount, setUnreadCount] = useState(0);

  const { data, isLoading, refetch, isRefetching } = useQuery({
    queryKey: ['notifications'],
    queryFn: () => api.get<AppNotification[]>('/notifications'),
  });

  const markReadMutation = useMutation({
    mutationFn: (id: string) => api.patch(`/notifications/${id}/read`),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['notifications'] });
    },
  });

  const markAllReadMutation = useMutation({
    mutationFn: () => api.post('/notifications/mark-all-read'),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['notifications'] });
      Notifications.setBadgeCountAsync(0);
    },
  });

  // Listen for new notifications while app is open
  useEffect(() => {
    const sub = Notifications.addNotificationReceivedListener((notification) => {
      queryClient.invalidateQueries({ queryKey: ['notifications'] });
    });
    return () => sub.remove();
  }, [queryClient]);

  // Update badge count
  useEffect(() => {
    const count = data?.filter((n) => !n.isRead).length ?? 0;
    setUnreadCount(count);
    Notifications.setBadgeCountAsync(count);
  }, [data]);

  return (
    <View className="flex-1 bg-white">
      <View className="flex-row items-center justify-between px-4 py-3 border-b border-gray-100">
        <Text className="text-lg font-semibold text-gray-900">
          Notifications {unreadCount > 0 && `(${unreadCount})`}
        </Text>
        {unreadCount > 0 && (
          <Pressable onPress={() => markAllReadMutation.mutate()}>
            <Text className="text-blue-600 text-sm">Mark all read</Text>
          </Pressable>
        )}
      </View>

      <FlatList
        data={data ?? []}
        keyExtractor={(item) => item.id}
        refreshControl={
          <RefreshControl refreshing={isRefetching} onRefresh={refetch} />
        }
        renderItem={({ item }) => (
          <Pressable
            onPress={() => {
              if (!item.isRead) markReadMutation.mutate(item.id);
              // Navigate to deep link target
              if (item.data.screen) {
                handleNotificationNavigation(item.data as NotificationData);
              }
            }}
            className={`px-4 py-3 border-b border-gray-50 ${
              !item.isRead ? 'bg-blue-50' : 'bg-white'
            }`}
          >
            <View className="flex-row items-start">
              {!item.isRead && (
                <View className="w-2 h-2 rounded-full bg-blue-500 mt-1.5 mr-2" />
              )}
              <View className="flex-1">
                <Text className="font-medium text-gray-900">{item.title}</Text>
                <Text className="text-gray-500 text-sm mt-0.5">{item.body}</Text>
                <Text className="text-gray-400 text-xs mt-1">
                  {new Date(item.createdAt).toLocaleString()}
                </Text>
              </View>
            </View>
          </Pressable>
        )}
        ListEmptyComponent={
          <View className="items-center py-16">
            <Text className="text-gray-400">No notifications yet</Text>
          </View>
        }
      />
    </View>
  );
}

Cost Reference

VolumeExpo Push APIFCM DirectAPNs Direct
< 1M/moFreeFreeFree
1Mโ€“5M/mo$0.001/notificationFreeFree
5Mโ€“50M/moContact ExpoFreeFree
50M+/moDirect FCM/APNs recommendedFreeFree

See Also


Working With Viprasol

Building a React Native app that needs push notifications with deep link routing, a notification center UI, and reliable server-side delivery? We implement end-to-end push notification infrastructure โ€” from Expo token registration and APNs/FCM setup through delivery tracking and in-app notification center โ€” that works across iOS and Android.

Talk to our team โ†’ | See our web development services โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.