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.
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
| Volume | Expo Push API | FCM Direct | APNs Direct |
|---|---|---|---|
| < 1M/mo | Free | Free | Free |
| 1Mโ5M/mo | $0.001/notification | Free | Free |
| 5Mโ50M/mo | Contact Expo | Free | Free |
| 50M+/mo | Direct FCM/APNs recommended | Free | Free |
See Also
- React Native Animations: Reanimated 3 and Gesture Handler
- React Native Performance Optimization
- React Native Offline: AsyncStorage, SQLite, and Conflict Resolution
- React Native Expo EAS: Build Profiles, OTA Updates, and CI/CD
- SaaS Email Sequences: Transactional System and Drip Campaigns
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.
About the Author
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.
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
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.