Push Notifications
Register a device, get a push token, and set up the APNs and FCM credentials a real push needs.
const { data } = await Notifications.getExpoPushTokenAsync({ projectId })A push notification comes from a server. Before one can arrive, the device registers with Apple's APNs or Google's FCM and gives you a token. The token is the address you send to.
Two kinds of token
getExpoPushTokenAsync({ projectId }) returns an Expo token that looks like ExponentPushToken[...]. Your backend posts to Expo's push service, which forwards to APNs and FCM for you. getDevicePushTokenAsync() returns the raw APNs or FCM token instead, as { type, data }, for a backend that talks to those services itself. Going direct means handling APNs HTTP/2 with JWT auth and FCM v1 yourself; the Expo service exists to avoid that.
Credentials
Android needs a Firebase project: put google-services.json in your app and upload an FCM v1 service account key to EAS. iOS needs a paid Apple Developer account and an APNs key, which EAS can create during eas build. Without those, registration succeeds locally and nothing is ever delivered.
Device only
Push needs a real device, an Android emulator with Google Play services, or an iOS simulator on Xcode 14 or newer. Guard with Device.isDevice from expo-device. Since SDK 53, remote push does not work in Expo Go on Android — you need a development build. Local notifications still work in Expo Go.
Note the shorthand: Constants.expoConfig?.extra means Constants.expoConfig && Constants.expoConfig.extra, and a ?? b is a != null ? a : b.
Example
import { useEffect, useState } from 'react';
import { Platform, StyleSheet, Text, View } from 'react-native';
import Constants from 'expo-constants';
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
async function getPushTokenAsync(): Promise<string | null> {
// Emulators without Google Play services cannot register at all.
if (!Device.isDevice) return null;
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
});
}
const current = await Notifications.getPermissionsAsync();
let status = current.status;
if (status !== 'granted') {
const asked = await Notifications.requestPermissionsAsync();
status = asked.status;
}
if (status !== 'granted') return null;
// projectId ties the token to your EAS project and keeps it valid across transfers.
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = await Notifications.getExpoPushTokenAsync({ projectId });
return token.data; // ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]
}
export default function PushSetupScreen() {
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getPushTokenAsync()
.then(setToken)
.catch((e: Error) => setError(e.message));
}, []);
// Screen: a label and one selectable line of text. It reads "registering...",
// then becomes the token after the permission dialog is accepted, or the error
// message if registration failed. Long-press to copy the token for testing.
return (
<View style={styles.screen}>
<Text style={styles.label}>Expo push token</Text>
<Text selectable>{error ?? token ?? 'registering...'}</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, justifyContent: 'center', padding: 24, gap: 8 },
label: { fontWeight: '600' },
});When to use it
- Collect push tokens for signed-in users so your backend can notify them later.
- Decide whether to route pushes through Expo's service or straight to APNs and FCM.
- Set up Android FCM and iOS APNs credentials before the first release build.
- Explain to a teammate why push never arrives in Expo Go on their Android phone.
More examples
The app config that makes push build correctly
These keys are read at build time, not at runtime, so changing them requires a new build rather than a reload. The expo-notifications plugin controls the small monochrome Android status-bar icon and its tint; omit it and Android shows a grey square. googleServicesFile points at the file you downloaded from Firebase. The projectId under extra.eas is what the runtime code reads through expo-constants.
{
"expo": {
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#1F6FEB",
"defaultChannel": "default"
}
]
],
"android": {
"googleServicesFile": "./google-services.json"
},
"extra": {
"eas": { "projectId": "00000000-0000-0000-0000-000000000000" }
}
}
}The raw APNs or FCM token
Reach for this when push sending already exists on your server for other platforms and you do not want a second delivery path. The cost is real: you now own APNs JWT signing, FCM v1 service account auth, per-platform payload shapes and your own retry logic. An Expo token hides all of that behind one HTTP endpoint, so prefer it unless something specific rules it out.
import * as Notifications from 'expo-notifications';
type NativeToken = { type: string; data: unknown };
// Only for a backend that speaks APNs and FCM itself.
export async function getNativeTokenAsync(): Promise<NativeToken> {
const token = await Notifications.getDevicePushTokenAsync();
// token.type is 'ios' or 'android'; token.data is the raw device token.
return { type: token.type, data: token.data };
}Sending the token to your own API after sign-in
Tokens belong to a device, not a user, so the server table should be keyed on the token with the user id as a column. Posting on every launch, with the token as the unique key, keeps the table correct when a user signs out and someone else signs in on the same phone. Delete the row on sign-out so the previous user stops receiving that device's pushes.
import { useEffect } from 'react';
// Call this from a screen that renders once the user is signed in.
export function useRegisterPushToken(userId: string | null, token: string | null) {
useEffect(() => {
// The effect runs on every change, so bail out until both values exist.
if (!userId || !token) return;
let cancelled = false;
fetch('https://api.example.com/devices', {
method: 'POST',
headers: { 'content-type': 'application/json' },
// Store platform too: Android needs a channelId when you send.
body: JSON.stringify({ userId, token, platform: 'expo' }),
}).catch(() => {
if (!cancelled) {
// Registration is best-effort; retry on the next launch.
}
});
return () => {
cancelled = true;
};
}, [userId, token]);
}
Discussion