Push Notifications in Expo: expo-notifications & OneSignal in Production
Mobile15 min read

Push Notifications in Expo: expo-notifications & OneSignal in Production

A production-grade guide to push notifications in Expo: local vs remote, permissions and Android channels, Expo push tokens, the Expo Push API, deep linking, and when to graduate to OneSignal.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

Mar 18, 2026
#Expo#React Native#Push Notifications#OneSignal#expo-notifications#APNs#FCM

Push notifications in Expo are delivered through two mechanisms: local notifications, scheduled and fired entirely on the device by the expo-notifications library, and remote notifications, sent from a server through Apple Push Notification service (APNs) on iOS or Firebase Cloud Messaging (FCM) on Android. In a typical Expo setup, the app requests permission, registers for an Expo push token, and your backend sends messages to the Expo Push API, which routes them to APNs and FCM using credentials managed by EAS. Production concerns go well beyond the happy path: Android requires notification channels for importance and sound, iOS will not show remote pushes on simulators, and notification taps must deep link to the right screen. For advanced needs such as segmentation, analytics, and marketing campaigns, OneSignal integrates through onesignal-expo-plugin. This guide covers the full path from a single local reminder to server-driven remote push at scale.

Push notifications are one of those features that look trivial in a demo and turn into a swamp in production. You wire up a token, send a test message, see it pop on the simulator, and ship. Then the support tickets arrive: notifications never fire when the app is killed, iOS shows nothing while Android works fine, taps do not open the right screen, and half your users never got prompted for permission. This guide walks through the whole path in Expo, from a single local reminder to a server-driven fleet of remote pushes, and explains when expo-notifications is enough and when OneSignal earns its place.

What Is the Difference Between Local and Remote Notifications?

The first thing to get straight is that "push notification" is really two very different mechanisms wearing the same UI. A local notification is scheduled and fired entirely on the device by your own app code. There is no network involved, no server, and no push token. It is perfect for reminders, timers, and anything the device already knows about. A remote notification originates on a server and travels through Apple Push Notification service (APNs) on iOS or Firebase Cloud Messaging (FCM) on Android before the OS wakes your app and displays it. Remote is what you need for chat messages, breaking news, marketing, or anything driven by events that happen away from the phone.

Rule of thumb: if the device already knows what to say and when, use a local notification. If a server decides, it must be a remote push. Never poll a backend in the background just to fire a "local" notification you could have pushed.

Setting Up expo-notifications

Install the library and, on iOS, add the config plugin so the native project is configured during prebuild. On a bare or development build you also need the notification entitlement, which the plugin wires up for you. Note that remote push does not work in Expo Go on iOS as of SDK 53+, so you will be running a development build for any serious work.

bash
npx expo install expo-notifications expo-device expo-constants

# Build a dev client (remote push does not work in Expo Go on iOS)
eas build --profile development --platform ios
eas build --profile development --platform android

Register the config plugin in app.json. The plugin lets you set a default notification icon, color, and custom sounds so the OS renders your brand instead of a generic bell.

json
{
  "expo": {
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#0F172A",
          "defaultChannel": "default",
          "sounds": ["./assets/sounds/chime.wav"]
        }
      ]
    ],
    "ios": {
      "infoPlist": {
        "UIBackgroundModes": ["remote-notification"]
      }
    }
  }
}

How Do You Request Permissions the Right Way?

On iOS you must explicitly ask for permission, and you only get one clean shot at the system dialog. If the user denies it, the OS will never show the prompt again and you have to send them to Settings. Because of that, never ask on first launch. Ask at a moment where the value is obvious, for example right after the user enables a reminder or joins a chat. Android grants notification permission automatically below API 33, but on Android 13+ you must request POST_NOTIFICATIONS just like iOS.

typescript
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';

export async function requestNotificationPermission(): Promise<boolean> {
  if (!Device.isDevice) {
    // Push tokens are only available on physical devices.
    return false;
  }

  const { status: existing } = await Notifications.getPermissionsAsync();
  let finalStatus = existing;

  // Only prompt if we have not already been granted or denied.
  if (existing !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync({
      ios: {
        allowAlert: true,
        allowBadge: true,
        allowSound: true,
      },
    });
    finalStatus = status;
  }

  return finalStatus === 'granted';
}

Android Notification Channels

On Android 8+ every notification must belong to a channel, and the channel, not your code, controls importance, sound, vibration, and whether the notification can interrupt the user with a heads-up banner. If you never create a channel, Android drops your notification into a low-importance default and users wonder why nothing ever pops up. Create your channels once at startup, and create separate channels for genuinely different categories so users can mute marketing while keeping chat alerts.

typescript
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';

export async function setupAndroidChannels(): Promise<void> {
  if (Platform.OS !== 'android') return;

  await Notifications.setNotificationChannelAsync('messages', {
    name: 'Messages',
    importance: Notifications.AndroidImportance.MAX,
    vibrationPattern: [0, 250, 250, 250],
    lightColor: '#0F172A',
    sound: 'chime.wav', // file bundled via the config plugin
  });

  await Notifications.setNotificationChannelAsync('marketing', {
    name: 'Promotions',
    importance: Notifications.AndroidImportance.LOW,
  });
}

Getting a Push Token: Expo Token vs Native Device Token

There are two kinds of token, and mixing them up is a classic source of "my pushes never arrive" bugs. The Expo push token (an ExponentPushToken[...] string) is what you use with the Expo Push API and Expo's notification service, which brokers delivery to APNs and FCM for you. The native device token is the raw APNs or FCM token you would need if you talk to Apple or Google directly, or through a third party like OneSignal. For a standard Expo backend flow you want the Expo token; request it with your EAS project ID.

typescript
import * as Notifications from 'expo-notifications';
import Constants from 'expo-constants';

export async function getExpoPushToken(): Promise<string | null> {
  const granted = await requestNotificationPermission();
  if (!granted) return null;

  const projectId =
    Constants.expoConfig?.extra?.eas?.projectId ??
    Constants.easConfig?.projectId;

  if (!projectId) {
    throw new Error('Missing EAS projectId; set it in app config.');
  }

  const { data: expoPushToken } = await Notifications.getExpoPushTokenAsync({
    projectId,
  });

  // Persist this on your backend, keyed by user + device.
  return expoPushToken; // ExponentPushToken[xxxxxxxx]
}

// If you talk to APNs/FCM directly instead of the Expo service:
export async function getNativeDeviceToken(): Promise<string> {
  const { data } = await Notifications.getDevicePushTokenAsync();
  return data; // raw APNs or FCM token
}

Store tokens server-side keyed by both user and device, and refresh them on every app launch. Tokens rotate: they change on reinstall, on restore to a new phone, and occasionally at the whim of the OS. A stale token is a silently dropped notification.

Scheduling Local Notifications

Local notifications are scheduled with a trigger. You can fire after a delay, at a specific date, or on a repeating calendar pattern such as every morning at 9. The content object carries the title, body, an optional sound, and a data payload you can read back when the user taps it, which is essential for deep linking.

typescript
import * as Notifications from 'expo-notifications';

// Fire once, 60 seconds from now.
export async function scheduleReminder(taskId: string) {
  return Notifications.scheduleNotificationAsync({
    content: {
      title: 'Time to review',
      body: 'Your task is due soon.',
      sound: 'chime.wav',
      data: { screen: 'TaskDetail', taskId },
    },
    trigger: {
      type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
      seconds: 60,
    },
  });
}

// Repeat every day at 09:00.
export async function scheduleDailyDigest() {
  return Notifications.scheduleNotificationAsync({
    content: { title: 'Daily digest', body: 'Here is what you missed.' },
    trigger: {
      type: Notifications.SchedulableTriggerInputTypes.DAILY,
      hour: 9,
      minute: 0,
    },
  });
}

// Cancel one, or clear everything.
export async function cancelReminder(id: string) {
  await Notifications.cancelScheduledNotificationAsync(id);
}

Handling Received Events and Foreground Behavior

By default iOS suppresses the banner when a notification arrives while your app is in the foreground, because the assumption is that the user is already looking at the relevant content. If you want a banner regardless, set a notification handler. This handler runs for every incoming notification and decides how it should be presented.

typescript
import * as Notifications from 'expo-notifications';

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

There are two listener events you care about. The received listener fires when a notification arrives while the app is running, which is handy for updating an in-app badge or refreshing data. The response listener fires when the user taps a notification, whether the app was in the foreground, backgrounded, or fully killed. Register both inside a useEffect and remove them on cleanup.

tsx
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';

export function useNotificationObservers() {
  const receivedRef = useRef<Notifications.EventSubscription>();
  const responseRef = useRef<Notifications.EventSubscription>();

  useEffect(() => {
    // Arrived while the app was open.
    receivedRef.current = Notifications.addNotificationReceivedListener(
      (notification) => {
        console.log('received', notification.request.content.data);
      }
    );

    // User tapped the notification (foreground, background, or killed).
    responseRef.current =
      Notifications.addNotificationResponseReceivedListener((response) => {
        routeFromNotification(response.notification.request.content.data);
      });

    // Handle the case where a killed app was opened by a tap.
    Notifications.getLastNotificationResponseAsync().then((response) => {
      if (response) {
        routeFromNotification(response.notification.request.content.data);
      }
    });

    return () => {
      receivedRef.current?.remove();
      responseRef.current?.remove();
    };
  }, []);
}

function routeFromNotification(data: Record<string, unknown>) {
  if (data?.screen === 'TaskDetail' && data.taskId) {
    router.push(`/tasks/${data.taskId}`);
  }
}

Sending with the Expo Push API

Once you have stored Expo push tokens, sending is a single HTTPS POST to Expo's push endpoint. Expo relays the message to APNs or FCM using credentials it manages for you, which is the whole appeal: you never touch a p8 key or a service account JSON. Send in batches of up to 100 messages per request, and always read the response tickets.

typescript
type ExpoPushMessage = {
  to: string;
  title: string;
  body: string;
  data?: Record<string, unknown>;
  sound?: 'default' | null;
  channelId?: string; // maps to your Android channel
  badge?: number;
};

export async function sendPush(messages: ExpoPushMessage[]) {
  const res = await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(messages),
  });

  const { data: tickets } = await res.json();
  // Each ticket has status 'ok' or 'error'. On 'error' inspect
  // details.error: 'DeviceNotRegistered' means delete the token.
  return tickets;
}

Tickets are not delivery confirmations. A ticket with status ok returns a receipt id that you should poll a few minutes later against the receipts endpoint. That receipt is where you learn about real failures, most importantly DeviceNotRegistered, which tells you to purge the token so you stop wasting sends on a device that uninstalled your app.

When Should You Reach for OneSignal?

expo-notifications plus the Expo Push API is an excellent, free foundation, and for many apps it is all you will ever need. But it is a delivery pipe, not a marketing platform. The moment your requirements shift from "send this message to these tokens" to "send this campaign to everyone in Germany who opened the app in the last 7 days but has not purchased," you are rebuilding a CRM. That is where a dedicated provider like OneSignal pays off.

What OneSignal gives you that a raw push pipe does not:

  • Segmentation and tags so non-engineers can target users by behavior, geography, or custom attributes
  • A dashboard to compose, schedule, and A/B test campaigns without a deploy
  • Delivery analytics: sent, delivered, opened, and conversion rates per campaign
  • Automated journeys and drip sequences triggered by user events
  • Built-in handling of token lifecycle, unsubscribes, and quiet hours
  • In-app messages and email/SMS channels alongside push, from one SDK

The tradeoff is another SDK, another dashboard, and pricing above a free tier. My advice: start with expo-notifications, and adopt OneSignal when a marketing or growth team needs to own messaging without shipping code for every campaign.

Integrating onesignal-expo-plugin

OneSignal ships an Expo config plugin so you get a fully configured native build without ejecting. Install the SDK and the plugin, then register the plugin in app config with your OneSignal App ID and the notification service extension mode for iOS rich notifications.

bash
npx expo install react-native-onesignal onesignal-expo-plugin
json
{
  "expo": {
    "plugins": [
      [
        "onesignal-expo-plugin",
        {
          "mode": "development"
        }
      ]
    ],
    "extra": {
      "oneSignalAppId": "your-onesignal-app-id"
    }
  }
}
tsx
import { LogLevel, OneSignal } from 'react-native-onesignal';
import Constants from 'expo-constants';

export function initOneSignal() {
  OneSignal.Debug.setLogLevel(LogLevel.Warn);
  OneSignal.initialize(Constants.expoConfig?.extra?.oneSignalAppId);

  // Prompt at a value moment, not on launch.
  OneSignal.Notifications.requestPermission(true);

  // Tie the device to your own user id for cross-device targeting.
  OneSignal.login('user-123');

  // Tags power segmentation from the dashboard.
  OneSignal.User.addTags({ plan: 'pro', locale: 'en' });

  // Deep link when a notification is tapped.
  OneSignal.Notifications.addEventListener('click', (event) => {
    const data = event.notification.additionalData as { screen?: string };
    if (data?.screen) router.push(data.screen);
  });
}

APNs and FCM Credentials

No matter which path you take, the notification eventually goes through Apple and Google, and both demand credentials. For iOS you provide an APNs authentication key, a p8 file created in the Apple Developer portal, which is preferable to the older certificate approach because one key works across all your apps and never expires. For Android you provide Firebase credentials: the google-services.json for the app and, for server sends, a service account key using the FCM v1 API.

The convenience of the Expo path is that EAS manages the APNs key for you, generating and uploading it during the credentials step, so you rarely handle the p8 by hand. With OneSignal you upload the same p8 and the FCM service account into the OneSignal dashboard, and OneSignal talks to Apple and Google on your behalf. Either way, the underlying credentials are identical; only who holds them differs.

bash
# Let EAS create and manage your APNs key and FCM setup.
eas credentials

# Inspect what is configured per platform.
eas credentials --platform ios
eas credentials --platform android

How Do You Deep Link from a Notification?

A notification that opens the home screen is a missed opportunity. The whole point is to drop the user exactly where the content lives. The pattern is consistent across both expo-notifications and OneSignal: put routing information in the notification data payload, read it in the tap handler, and navigate. With Expo Router you can push a typed route directly.

typescript
// Server side: include routing data in the push payload.
await sendPush([
  {
    to: expoToken,
    title: 'New message from Aylin',
    body: 'Hey, are we still on for tomorrow?',
    channelId: 'messages',
    data: {
      url: '/chat/aylin-42', // an Expo Router path
      conversationId: 'aylin-42',
    },
  },
]);

// Client side: a single resolver used by every tap handler.
function routeFromNotification(data: Record<string, unknown>) {
  if (typeof data?.url === 'string') {
    router.push(data.url);
  }
}

Remember the killed-app case: when a user taps a notification that launches a cold app, your listeners are not registered yet at the moment of the tap. That is exactly why getLastNotificationResponseAsync (shown earlier) matters. Call it once on startup so a cold-start tap still routes correctly instead of silently landing on the home screen.

A Two-Tier Strategy: Local Reminders + Server Push

The most resilient production setups combine both mechanisms. Use local notifications for anything the device can decide on its own, and reserve remote push for server-driven events. This keeps your backend load low, works even when the device is offline, and gives users instant feedback for their own actions.

How to split responsibilities:

  • Local tier: "your timer is up", "daily 9am habit reminder", "trial expires tomorrow" — all schedulable on-device the moment the user sets them up
  • Remote tier: "you received a message", "your order shipped", "a friend joined" — events only the server knows about
  • Cancel local reminders the instant the underlying task is completed, so a user who finishes early is never nagged
  • Never fabricate a local notification from a background fetch to fake a server push; background execution is unreliable and the OS will throttle you

Testing and Common Pitfalls

Testing push properly means testing on real hardware, because push tokens are unavailable on the iOS simulator and unreliable on Android emulators without Google Play services. Expo provides a browser-based push notification tool where you can paste an Expo token and fire a test message, which is the fastest way to validate the round trip before wiring your backend.

The pitfalls that bite almost everyone:

  • Permissions asked on first launch and denied forever — ask at a value moment, and detect denial to route users to Settings
  • Killed-app delivery on Android — aggressive OEM battery optimizers (Xiaomi, Huawei, Samsung) silently kill background delivery; test on those brands and guide users to whitelist your app
  • Missing iOS entitlements — the aps-environment entitlement and Push Notifications capability must be present, or APNs silently rejects everything; EAS handles this but bare projects often forget
  • Wrong token type — sending a native APNs token to the Expo Push API, or an Expo token to OneSignal, results in silent no-ops
  • No Android channel — notifications land in a low-importance bucket with no sound or banner
  • Stale tokens never purged — always act on DeviceNotRegistered receipts to keep your token table clean
  • Foreground silence — forgetting the notification handler makes iOS look broken when the app is open

Push notifications fail quietly. There is rarely an exception in your logs — the message just never appears. Treat receipts, token hygiene, and real-device testing as first-class parts of the feature, not afterthoughts.

Key Takeaways

What to remember when you build push in Expo:

  • Local notifications need no server or token; remote notifications always travel through APNs or FCM
  • Ask for permission at a value moment, never on first launch, and handle the denied-forever case on iOS
  • Create Android channels explicitly — the channel, not your code, controls importance and sound
  • The Expo push token feeds the Expo Push API; the native device token is for APNs/FCM or OneSignal directly — do not mix them
  • Register both received and response listeners, and call getLastNotificationResponseAsync for cold-start taps
  • Tickets are not delivery; poll receipts and purge tokens on DeviceNotRegistered
  • Start with expo-notifications; adopt OneSignal when you need segmentation, dashboards, and campaign analytics
  • Put routing data in the payload for deep linking, and combine a local reminder tier with a server push tier for resilience
  • Test on real devices, including battery-aggressive Android OEMs, and confirm iOS entitlements are present

Share this article

Related Articles