TL;DR
- Remote push doesn't work in Expo Go on Android since SDK 53. Use a development build.
- Android needs FCM V1 credentials on EAS; iOS needs a paid Apple account and an APNs key.
- Create the Android notification channel before asking permission, or the prompt never shows on Android 13+.
- Pass
projectIdtogetExpoPushTokenAsyncexplicitly. - A ticket saying
okisn't delivery. Check receipts.
1. Install
npx expo install expo-notifications expo-constants
2. Credentials + dev build
| Platform | Needs |
|---|---|
| Android | FCM V1 credentials uploaded to EAS |
| iOS | Paid Apple Developer account + APNs key (EAS CLI offers to create it) |
eas build --profile development --platform android
eas build --profile development --platform ios
Register your iOS test device before the first build.
3. Permission + token
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
export async function registerForPushNotifications() {
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
});
}
const { status: existing } = await Notifications.getPermissionsAsync();
let status = existing;
if (existing !== 'granted') {
({ status } = await Notifications.requestPermissionsAsync());
}
if (status !== 'granted') return null;
const projectId =
Constants?.expoConfig?.extra?.eas?.projectId ??
Constants?.easConfig?.projectId;
if (!projectId) throw new Error('Missing EAS projectId');
return (await Notifications.getExpoPushTokenAsync({ projectId })).data;
}
Save the token on your backend against the user. And add a handler, or foreground notifications stay hidden:
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
4. Send one
Fastest: paste the token into expo.dev/notifications. From a server:
curl -X POST https://exp.host/--/api/v2/push/send \
-H "Content-Type: application/json" \
-d '{"to":"ExponentPushToken[xxx]","title":"Order shipped","body":"Tap to track","data":{"url":"myapp://orders/42"}}'
You get a ticket back. Fetch the receipt later for real delivery errors like DeviceNotRegistered.
5. Handle taps
useEffect(() => {
const sub = Notifications.addNotificationResponseReceivedListener((res) => {
const url = res.notification.request.content.data?.url;
if (typeof url === 'string') Linking.openURL(url);
});
return () => sub.remove();
}, []);
With Expo Router, myapp://orders/42 lands on app/orders/[id].tsx.
Didn't arrive? Check this
| Symptom | Cause |
|---|---|
| Nothing in Expo Go (Android) | No remote push there since SDK 53 |
| No permission prompt (Android) | Channel not created first |
| Token call throws | Missing projectId or credentials |
Ticket ok, nothing shows |
Check receipt + device notification settings |
| Only fails in foreground | No setNotificationHandler
|
DeviceNotRegistered |
Stale token. Delete it. |
| Android fails, iOS fine | FCM V1 creds missing / wrong project |
Skipping the boilerplate
If you'd rather not hand-build the screens your notifications open, I work on RapidNative, which generates a standard Expo project (Expo Router, TypeScript, NativeWind) from a prompt. Export it and drop the code above in.
What broke your first Expo push: credentials, tokens, or the foreground handler?
Top comments (1)
The receipt check is the right boundary between accepted and delivered. I'd also persist each ticket ID with the user/device record, so a DeviceNotRegistered receipt can disable just that stale token, while transient provider errors can be retried without resending to every device. Do you keep ticket IDs linked to the tokens you send to?