Quick path
Install expo-notifications, create a dev build via EAS, upload APNs key + FCM credentials in EAS credentials, request permission, store the Expo push token, and send from your server via the Expo Push API. Expect 1–2 hours end-to-end on a clean project.
Local vs remote: pick the right one
Not every app needs a backend to send notifications. Two categories:
- Local notifications: scheduled by the app itself — daily reminders, habit streaks, timers. No server. Works in Expo Go. Use for solo-user apps where reminders are the core feature (habit trackers, meditation, fitness).
- Remote notifications: triggered by your backend — new messages, order updates, social notifications. Requires APNs and FCM credentials plus a development build. Use whenever an event happens server-side.
Prerequisites before you write code
- Apple Developer account with a registered App ID and an APNs Auth Key (.p8 file from developer.apple.com)
- A Firebase project with the Android app registered and google-services.json downloaded
- An Expo project using EAS Build (Expo Go is insufficient for remote push)
- A physical iOS device for testing — simulators cannot receive remote notifications
Step 1: install and configure
npx expo install expo-notifications expo-device
# app.json plugins:
# "plugins": [["expo-notifications", {
# "icon": "./assets/notification-icon.png",
# "color": "#fb923c"
# }]]
# Upload credentials to EAS:
eas credentials
# Pick iOS -> Push Notifications -> add APNs key
# Pick Android -> FCM V1 -> add google-services.json
# Build a development client:
eas build --profile development --platform allInstall the dev client on a physical device. Run npx expo start --dev-client instead of Expo Go.
Step 2: request permission and get the push token
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
async function registerForPush() {
if (!Device.isDevice) return;
const { status: existing } = await
Notifications.getPermissionsAsync();
let final = existing;
if (existing !== 'granted') {
const { status } = await
Notifications.requestPermissionsAsync();
final = status;
}
if (final !== 'granted') return;
const token = (await
Notifications.getExpoPushTokenAsync({
projectId: process.env.EXPO_PUBLIC_PROJECT_ID,
})).data;
// Send token to your backend:
await fetch('/api/push-tokens', {
method: 'POST',
body: JSON.stringify({ token }),
});
}Call registerForPush() once the user is authenticated — not on app launch. Users say yes more often when the ask comes after a clear value exchange.
Step 3: send from your server via Expo Push API
// Node.js with expo-server-sdk:
import { Expo } from 'expo-server-sdk';
const expo = new Expo();
const messages = [{
to: userPushToken,
sound: 'default',
title: 'New message',
body: 'Alex sent you a message.',
data: { screen: 'Chat', chatId: '42' },
}];
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
await expo.sendPushNotificationsAsync(chunk);
}The Expo Push API handles APNs and FCM routing for you. Chunking matters at scale — the API accepts up to 100 messages per request and rate-limits above that.
Step 4: handle notification taps
import * as Notifications from 'expo-notifications';
import { useEffect } from 'react';
import { router } from 'expo-router';
useEffect(() => {
const sub = Notifications.
addNotificationResponseReceivedListener(r => {
const data = r.notification.request.content.data;
if (data.screen === 'Chat')
router.push(`/chat/${data.chatId}`);
});
return () => sub.remove();
}, []);Deep-link directly to the relevant screen. Dropping users at the home screen after tapping a notification is a classic retention killer.
Common pitfalls
- Testing on simulator: iOS simulators cannot receive remote notifications. Use a physical device.
- Asking for permission on launch: conversion tanks. Ask after the user completes a first meaningful action.
- Token churn: push tokens change when users reinstall the app. Treat them as rotating — update your backend on every launch.
- Android battery savers can silently kill background delivery. OEM-specific (Xiaomi, OnePlus are the worst). Document this in your support docs.
- Not polling receipts: the Expo Push API returns receipt IDs that tell you delivery status 15+ minutes later. Poll them and clean up invalid tokens.
Shortcut: generate an app with push wired in
If you use ShipNative, describe your notification scenarios in the initial prompt (“send daily reminder,” “notify on new comment,” etc.) and the generated Expo project includes expo-notifications setup and the permission flow. You still need to upload APNs and FCM credentials yourself, but the JavaScript plumbing arrives ready.