---
title: Push notifications with Expo
description: "This article explains how to set up Releva push notifications in a React Native app that uses **Expo** and `expo-notifications`. It replaces only the installation and push sections of the [React Native integration](https://releva.ai/documentation/integrations/react-native-android-ios/): the client, visitor identity, tracking, banners, stories,…"
url: "https://releva.ai/documentation/uncategorized/push-notifications-with-expo/"
updated: "2026-10-16"
---

# Push notifications with Expo

This article explains how to set up Releva push notifications in a React Native app that uses **Expo** and `expo-notifications`. It replaces only the installation and push sections of the [React Native integration](https://releva.ai/documentation/integrations/react-native-android-ios/): the client, visitor identity, tracking, banners, stories, NPS, the App Inbox and the backend calls are set up exactly as described there.

Tested on Expo SDK 57 (React Native 0.86, expo-notifications 57, expo-task-manager 57, expo-router 57) with release builds on iOS 27 and Android 16. You need your own build of the app, not Expo Go: a development build (<code>npx expo run:android</code> / <code>run:ios</code> or EAS), a TestFlight build or a store release all work. Expo Go cannot load the <code>app.json</code> plugins below or run the background task that push relies on.

### What your users will see

|  | Android | iOS |
| --- | --- | --- |
| Push, app open | Title, body, action button | Drawn by iOS, as your notification handler allows |
| Push, app in the background or closed | Title, body, action button | Drawn by iOS |
| Action button | Yes | Only with a notification service extension in your app |
| Image | No | Only with a notification service extension in your app (JPEG or PNG) |
| Tap tracking and navigation | Yes: app open, in the background, or closed | Yes: app open, in the background, or closed |

iOS push needs **APNs import** enabled on your Releva account: the app registers its APNs token, and Releva converts it. Ask your account manager to enable it. Until then, Releva push does not reach iOS; every other feature works.

### 1. Install

```
npm install @releva-ai/sdk-react-native
npx expo install @react-native-async-storage/async-storage @react-native-community/netinfo \
  expo-notifications expo-task-manager expo-application expo-build-properties
```

Do not add `@react-native-firebase/messaging` to the same app. It and `expo-notifications` each register an Android Firebase messaging service, and Android hands each push to only one of them. You also do not need `@notifee/react-native`.

### 2. Configure app.json

```
{
  "expo": {
    "ios": { "bundleIdentifier": "com.example.shop" },
    "android": {
      "package": "com.example.shop",
      "googleServicesFile": "./google-services.json"
    },
    "plugins": [
      ["expo-notifications", { "enableBackgroundRemoteNotifications": true }],
      "expo-task-manager",
      ["expo-build-properties", { "ios": { "enableSceneSupport": true } }]
    ]
  }
}
```

- `googleServicesFile`: Android push goes through Firebase Cloud Messaging. Use the `google-services.json` of the Firebase project your Releva account sends through, for this app's package name. Ask your account manager which project that is.
- `enableBackgroundRemoteNotifications`: without it, iOS never wakes the app for a push that arrives in the background.
- `enableSceneSupport`: required for iOS builds made with Xcode 27, or the app is closed at launch on iOS 27. It needs expo 57.0.23 or later and expo-build-properties 57.0.22 or later. Expo SDK 58's template already does this; leave it out there.
- iOS needs no `GoogleService-Info.plist` on this stack.

Run `npx expo prebuild --clean` after changing `plugins`.

### 3. Entry file: the adapter and the background task

Both must be set up at module top level, before the app renders: when a push arrives with the app closed, Android starts your JavaScript only to run this task. Point `"main"` in `package.json` at this file.

```
// index.ts
import * as Notifications from 'expo-notifications';
import * as TaskManager from 'expo-task-manager';
import { EngagementTrackingService } from '@releva-ai/sdk-react-native';
import { expoNotificationsAdapter, extractRelevaPayload } from '@releva-ai/sdk-react-native/expo';
// With Expo Router, the custom entry file must load the router itself.
import 'expo-router/entry';

// Must run before the task can fire: on a cold start the task reads this adapter
// before the rest of the app has initialized.
EngagementTrackingService.setNotificationAdapter(expoNotificationsAdapter);

// Releva pushes that arrive while the app is in the background or closed
const RELEVA_BACKGROUND_TASK = 'RELEVA_BACKGROUND_NOTIFICATION_TASK';
TaskManager.defineTask(RELEVA_BACKGROUND_TASK, async ({ data }) => {
  const payload = extractRelevaPayload(data);
  if (payload) await EngagementTrackingService.handleBackgroundMessage(payload);
});
Notifications.registerTaskAsync(RELEVA_BACKGROUND_TASK).catch((e) =>
  console.warn('[Releva] Failed to register background notification task:', e),
);
```

### 4. App start: handler, permission, token, foreground listener

Run this where you initialize the Releva client (see the [React Native integration](https://releva.ai/documentation/integrations/react-native-android-ios/)), in place of its Firebase token code.

```
import * as Notifications from 'expo-notifications';
import * as Application from 'expo-application';
import { EngagementTrackingService } from '@releva-ai/sdk-react-native';
import {
  getDevicePushToken,
  isRelevaLocalNotification,
  relevaDataFromExpoNotification,
} from '@releva-ai/sdk-react-native/expo';

// Your app's own handler. A push that arrives while the app is open is shown
// only if this returns shouldShowBanner: true, on both platforms.
// Keep your existing handler if you already have one.
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: false,
  }),
});

await client.setDeviceId('<deviceId>');
await client.setProfileId('<profileId>');
await client.enablePushEngagementTracking({ onNotificationTapped }); // step 5

// Without the permission (iOS, and Android 13 and later) notifications are dropped silently.
await Notifications.requestPermissionsAsync();

// Android gives an FCM token; iOS gives an APNs token (needs APNs import on your account).
const devicePushToken = await getDevicePushToken();
if (devicePushToken?.tokenType === 'fcm') {
  await client.registerPushToken('android', devicePushToken.token);
} else if (devicePushToken?.tokenType === 'apns') {
  await client.registerPushToken('ios', devicePushToken.token, {
    tokenType: 'apns',
    bundleId: Application.applicationId!,
  });
}

// Pushes that arrive while the app is open, including silent inbox-sync pushes.
// The listener also fires for the notification the SDK itself schedules; skip
// that one, or every push re-displays itself forever.
Notifications.addNotificationReceivedListener((notification) => {
  const data = relevaDataFromExpoNotification(notification);
  if (data && !isRelevaLocalNotification(data)) {
    EngagementTrackingService.handleForegroundMessage(data);
  }
});
```

Use `relevaDataFromExpoNotification(notification)` wherever your own listeners look at a notification: it returns `null` for anything that is not a Releva push, so you can tell Releva's pushes from your own. On logout, re-register the token from `getDevicePushToken()` the same way, after setting the new profile id.

### 5. Taps and navigation with Expo Router

The SDK records the click and calls your `onNotificationTapped` with the push's target; navigating is your app's job:

```
import { Linking } from 'react-native';
import { router } from 'expo-router';
import type { RelevaNotificationAction } from '@releva-ai/sdk-react-native';

function onNotificationTapped(action: RelevaNotificationAction) {
  if (action.target === 'inbox') {
    router.push('/inbox');
  } else if (action.target === 'screen' && action.screen === 'product') {
    // action.screen is the free-form name set in the Releva dashboard
    router.push({ pathname: '/product/[id]', params: { id: String(action.parameters.productId) } });
  } else if (action.target === 'url' && action.url) {
    Linking.openURL(action.url);
  }
}
```

Call `client.checkInitialNotification()` once, after `enablePushEngagementTracking()` and after the root layout has mounted, for example from a `useEffect` in `app/_layout.tsx`. It routes the tap that launched a closed app; Expo Router refuses to navigate before the root layout mounts. A later normal launch does not route or record that tap again.

**Links in your own scheme.** Expo Router reads the host of a link such as `myapp://shop.example/product/3` as the first path segment and shows "Unmatched Route". Map such links in `app/+native-intent.tsx`:

```
export function redirectSystemPath({ path }: { path: string; initial: boolean }) {
  const host = 'shop.example';
  const at = path.indexOf(`${host}/`);
  return at === -1 ? path : path.slice(at + host.length);
}
```

### Known limitation

On Android, when the app is closed, `expo-notifications` shows its own copy of the push (title only) before your app's JavaScript starts. The SDK then shows the full notification and removes that copy, so one notification remains, but for about a second the shorter copy is visible and the phone can alert twice. With the app open or in the background, only the full notification appears.

### Troubleshooting

- **The iOS app closes at launch on iOS 27** ("UIScene life cycle is required for apps built with this SDK"): the build was made with Xcode 27 without scene support. Add the `enableSceneSupport` plugin from step 2 and run `npx expo prebuild --clean`. Builds made with Xcode 26 are unaffected.
- **iOS token registration fails** with `APNs token could not be imported into FCM. Status: INTERNAL.`: a development build has a sandbox APNs token. APNs import on your Releva account must be set to sandbox for development builds and to production for TestFlight and App Store builds.
- **The token listener loops.** If you re-register on `Notifications.addPushTokenListener`, register the token it hands you, and only when it differs from the last one. Do not call `getDevicePushToken()` inside it: each call fires the listener again.
- **Pushes do not show while the app is open:** your notification handler must return `shouldShowBanner: true` (step 4).
- **Pushes do not show while the app is closed:** check the plugins from step 2 and the entry file from step 3, and test on a real device with a release build. On Android, close the app by swiping it away, not with Settings → Force stop: after a force stop, Android blocks all pushes to the app until it is opened again.

Once push works, continue with the [React Native integration](https://releva.ai/documentation/integrations/react-native-android-ios/) for tracking, banners, stories, NPS and the App Inbox.
