ReadyNative

1 Oct 2026 · 9 min read

Expo push notifications, set up end to end

Push in an Expo app without ejecting: APNs key, FCM v1, the Expo push token, a test send, deep links on tap, and why Expo Go and simulators get nothing.

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

1. Link an EAS project

npx eas-cli@latest init

This 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-constants

Add 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

Read next: RevenueCat subscriptions in an Expo Router app, or the full Expo Notifications module docs.

More guides