DEV Community

Cover image for Expo Push Notifications: First Push in 5 Steps (and Why It Didn't Arrive)
Famitha M A
Famitha M A

Posted on Originally published at fami-blog.hashnode.dev

Expo Push Notifications: First Push in 5 Steps (and Why It Didn't Arrive)

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 projectId to getExpoPushTokenAsync explicitly.
  • A ticket saying ok isn't delivery. Check receipts.

1. Install

npx expo install expo-notifications expo-constants
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
  }),
});
Enter fullscreen mode Exit fullscreen mode

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"}}'
Enter fullscreen mode Exit fullscreen mode

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();
}, []);
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
davekurian profile image
Dave Kurian •

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?