Push notifications are documented in 14 places across Expo, Firebase, Apple, and React Native. None of them give you the whole picture. You bounce between docs for two days, get something working in dev, and then it breaks in production because you used a development cert.
This is the guide I wish I had on my first push integration.
The mental model first
Your backend
↓ sends push payload
Firebase Cloud Messaging (FCM)
↓ routes by platform
├── Android: delivers directly via FCM
└── iOS: forwards to APNs (Apple Push Notification service)
↓ delivers to device
Device shows notification
For iOS, FCM is a middleman. The actual delivery always goes through APNs. This is why you need an APNs key even when you're "only using Firebase."
Step 1: Get the APNs key (the part everyone messes up)
Apple gives you two ways to send pushes:
- APNs certificate (.p12), per-app, expires every year, painful
- APNs key (.p8), one key for all your apps, doesn't expire, the only sane option
Get the key:
- Apple Developer → Certificates, Identifiers & Profiles → Keys
- Create a key with Apple Push Notifications service (APNs) enabled
- Note the Key ID and Team ID, you'll need both
- Download the
.p8file (you can only download it once)
This single key works for every iOS app you ever build. Treat it like a production secret.
Step 2: Upload to Firebase
In Firebase Console → Project Settings → Cloud Messaging → upload the .p8 file under APNs Authentication Key.
Enter the Key ID and Team ID from step 1. Firebase can now send pushes to iOS on your behalf.
Step 3: Set up React Native (bare workflow)
For a bare React Native project, install @react-native-firebase/messaging:
npm install @react-native-firebase/messaging @react-native-firebase/appFollow the Firebase iOS setup: download GoogleService-Info.plist from Firebase and add it to your Xcode project. For Android, download google-services.json and place it in android/app/.
Step 3 alternative: Expo
If you're on Expo (which I recommend for new projects), use expo-notifications:
npx expo install expo-notificationsAdd to app.json:
{
"expo": {
"plugins": ["expo-notifications"]
}
}Step 4: Request permission (don't blow this)
The number that matters: iOS opt-in rate. If you ask on launch, you'll get 30%. If you ask after the user has used the app for a minute, you'll get 60-70%.
import * as Notifications from 'expo-notifications'
async function requestPermissions() {
const { status } = await Notifications.requestPermissionsAsync({
ios: {
allowAlert: true,
allowBadge: true,
allowSound: true,
},
})
return status === 'granted'
}Don't call this on mount. Call it after the user does something that would benefit from notifications, favorited an item, started a chat, scheduled a reminder.
Step 5: Register the token
import * as Notifications from 'expo-notifications'
async function registerForPushNotifications(userId) {
const token = (await Notifications.getDevicePushTokenAsync()).data
await fetch('https://your-api.com/push/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, token }),
})
}
// Re-register on every launch: tokens can rotate
useEffect(() => {
registerForPushNotifications(currentUserId)
}, [])On the backend, store tokens per user per device. A user with a phone and a tablet has two tokens. Send to both.
Step 6: Handle the three states
A push can arrive when your app is:
// 1. FOREGROUND: app is open and visible
Notifications.addNotificationReceivedListener(notification => {
// Show an in-app banner. The OS does NOT show anything by default.
showInAppBanner(notification.request.content)
})
// 2. BACKGROUND: app is minimized
// The OS shows the notification. Your code doesn't run until the user taps.
// 3. KILLED: app is not running
// The OS shows the notification. When the user taps, your app launches.
Notifications.getLastNotificationResponseAsync().then(response => {
if (response) {
const data = response.notification.request.content.data
deepLinkTo(data.screen, data.params)
}
})The foreground state is the one most teams miss. iOS does NOT show a banner when the app is open, you have to build one. Android 14+ can show the system banner if you set a priority.
Step 7: Send from your backend
// Node.js with firebase-admin
import admin from 'firebase-admin'
admin.initializeApp({
credential: admin.credential.applicationDefault(),
})
const message = {
token: userDeviceToken,
notification: {
title: 'New message from Sarah',
body: 'Hey, are you free tonight?',
},
data: {
screen: 'chat',
chatId: 'abc123',
},
android: { priority: 'high' },
apns: { payload: { aps: { sound: 'default' } } },
}
await admin.messaging().send(message)The data field is what your React Native code reads to deep link. The notification field is what the OS shows. Both travel together.
The debugging checklist
When pushes don't arrive:
- APNs key uploaded to Firebase (not a cert)
- Permission actually granted (check app settings)
- Device token registered on backend
- Build is release, not debug (debug builds use different APNs environment)
- Test on a real device, not the simulator
- Firebase delivery reports show the push was sent
- Battery saver / focus mode isn't blocking on the device
The release-vs-debug one gets everyone. Debug builds talk to APNs sandbox, release builds talk to production. A push sent to production won't reach a debug build, and vice versa.
Push notifications are 10% React Native code and 90% understanding the Apple + Firebase + OS dance. Get the APNs key right, handle the three states, and debug on a real device with a release build.
Want help setting up push notifications?
I integrate push into React Native apps every month, permission flows, token management, deep linking, the full production setup. Let's talk.