You do not need to eject an Expo app to get push notifications. expo-notifications plus Expo's push service handle APNs (iOS) and FCM (Android) for you: your server sends one HTTPS request with an Expo push token, and Expo delivers it to Apple or Google. What you do need is a development build, a physical phone, and two credentials uploaded once.
Before you start: three things that waste an afternoon
- Expo Go gets no remote push since SDK 53 - no token on Android, and on iOS only with Expo's own credentials. Use a development build (see Expo Go vs a development build).
- Simulators never receive remote push. Test on a real iPhone and a real Android phone. Local notifications do work in simulators and Expo Go.
- No EAS project id, no token. getExpoPushTokenAsync needs the projectId of your EAS project.
1. Link an EAS project
npx eas-cli@latest initThis creates the project on expo.dev and gives you its id. With a static app.json it is written for you as extra.eas.projectId; with a dynamic app.config.ts, eas init can only print it, so add it to the config yourself. The token request reads it from Constants.expoConfig.extra.eas.projectId.
2. Upload the APNs key (iOS)
In the Apple Developer portal, create a key with Push Notifications enabled. Then run eas credentials, choose iOS → Push Notifications, and upload it. The key is per app, not per build profile: upload it once and every profile uses it.
3. Upload the FCM v1 service account (Android)
In the Firebase console, open Project settings → Service accounts and generate a private key (a JSON file). Run eas credentials again, choose Android → FCM V1, and upload the JSON. The legacy FCM server key is gone; v1 is the only option.
4. Install and configure expo-notifications
npx expo install expo-notifications expo-device expo-constantsAdd the config plugin so the Android build gets a notification icon. The status-bar icon must be monochrome (white on transparent), or Android shows a grey square:
// app.config.ts
plugins: [
["expo-notifications", {
icon: "./assets/images/android-icon-monochrome.png",
color: "#ffffff",
}],
],5. Ask for permission and get the token
Ask when the user understands why - after they turn on a reminder, not on first launch. A denied prompt on iOS cannot be shown again; the user has to go to Settings.
import Constants from "expo-constants";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
export async function registerForPush(): Promise<string | null> {
if (!Device.isDevice) return null; // simulators get no remote push
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "Default",
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const { status } = await Notifications.requestPermissionsAsync();
if (status !== "granted") return null;
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
if (!projectId) throw new Error("No EAS project id - run eas init");
const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
return data; // "ExponentPushToken[...]" - store it on your server
}Create the Android channel before you request the token: on Android 13 and later, the permission prompt only appears once a channel exists.
6. Show notifications in the foreground
By default a notification that arrives while the app is open is silent. Set a handler once, at startup:
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});7. Open the right screen on tap
Send a route in the payload's data and navigate to it when the user taps. Handle the cold start too - the tap that launched the app arrives before your listeners are attached:
import { router } from "expo-router";
// In your root layout:
useEffect(() => {
// The cold-start tap can arrive through both paths: handle each response once.
let handled: string | null = null;
const open = (r: Notifications.NotificationResponse) => {
const key = `${r.notification.request.identifier}:${r.actionIdentifier}`;
if (key === handled) return;
handled = key;
const url = r.notification.request.content.data?.url;
if (typeof url === "string") router.push(url);
};
const sub = Notifications.addNotificationResponseReceivedListener(open);
Notifications.getLastNotificationResponseAsync().then((last) => last && open(last));
return () => sub.remove();
}, []);8. Send a test
The quickest check is Expo's push tool at expo.dev/notifications: paste a token and send. From a script or your server it is one POST:
curl -X POST https://exp.host/--/api/v2/push/send \
-H "Content-Type: application/json" \
-d '{"to":"ExponentPushToken[xxx]","title":"Hi","body":"It works","data":{"url":"/settings"}}'The response is a ticket, not a delivery report. Errors like DeviceNotRegistered or bad credentials show up later in the push receipts, so check those (and drop dead tokens) from your server.
Checklist when nothing arrives
- Is it a development or store build on a physical device, not Expo Go or a simulator?
- Did eas credentials show the APNs key and the FCM v1 service account for this app?
- Does the bundle id / package name of the build match the credentials?
- Is the app in the background? In the foreground you need the handler from step 6.
- On Android, does a notification channel exist, and is it not muted in system settings?
- What do the push receipts say?
Read next: RevenueCat subscriptions in an Expo Router app, or the full Expo Notifications module docs.