Skip to content
Tutorials
Tutorials·9 min read·May 4, 2026

Push Notifications in React Native with Expo (2026 Guide)

End-to-end tutorial for shipping push notifications in a React Native + Expo app. Permissions, tokens, server delivery, deep links, and the production patterns that don't fall apart on day two.

Written by
Kaspar Noor
Push Notifications in React Native with Expo (2026 Guide)
What this guide covers

The production shape of push notifications for an Expo + React Native app in 2026: requesting permission at the right moment, registering tokens, sending notifications from your backend, handling taps with deep links, and the pieces that fail silently if you skip them.

This is the full setup, not the five-line snippet from the Expo docs. If you follow this top-to-bottom, you will end up with a notification system that survives app restarts, multiple devices per user, expired tokens, and the difference between iOS and Android.

If you want a project where this is already wired up, our React Native boilerplate chooser helps you pick a starter that ships with notifications working out of the box.

What "production push" actually means

Most tutorials stop at "show a notification when the app is open." That is the easy 5%. The real work is:

  • The user grants permission at a moment that does not feel intrusive.
  • The token is stored against the user's account, not just the device.
  • Your backend can target a user across multiple devices.
  • Tapping a notification opens the right screen, not the home screen.
  • Stale tokens get cleaned up, so you do not waste delivery attempts.
  • iOS and Android both work, and the differences are handled.

Skip any of those and the system breaks within a week of launch.

For an Expo app sending notifications via your own backend (Supabase, Convex, or a Node service), the architecture that works:

  1. The mobile app requests permission and gets an Expo push token.
  2. The mobile app sends the token to your backend along with the user ID.
  3. The backend stores tokens in a push_tokens table keyed by user.
  4. Your business logic calls a "send push" service.
  5. The service queries all tokens for the target user and sends to Expo's push API.
  6. Expo forwards to APNs (iOS) and FCM (Android).
  7. The app handles incoming notifications and routes taps via deep links.

If you use the Expo Push Service (free, no account beyond Expo), you skip a lot of certificate management. You can also send directly through APNs and FCM, but it is rarely worth the operational cost for a small team.

Step 1: install dependencies

npx expo install expo-notifications expo-device

expo-notifications is the SDK. expo-device lets you check whether you are on a real device (notifications do not work on simulators for iOS).

Step 2: configure app.json

{
  "expo": {
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#1f7a1f",
          "sounds": ["./assets/notification.wav"]
        }
      ]
    ],
    "ios": {
      "bundleIdentifier": "com.your.app",
      "infoPlist": {
        "UIBackgroundModes": ["remote-notification"]
      }
    },
    "android": {
      "package": "com.your.app",
      "googleServicesFile": "./google-services.json"
    }
  }
}

The Android googleServicesFile is required for FCM. Generate it from the Firebase console for your app's package name.

Step 3: request permission at the right moment

This is the most common mistake. Asking on launch tanks your opt-in rate.

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

async function registerForPushNotifications(): Promise<string | null> {
  if (!Device.isDevice) {
    console.log('Push notifications require a physical device');
    return null;
  }

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

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync();
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    return null;
  }

  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'Default',
      importance: Notifications.AndroidImportance.DEFAULT,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#1f7a1f',
    });
  }

  const token = (await Notifications.getExpoPushTokenAsync({
    projectId: 'your-expo-project-id',
  })).data;

  return token;
}

Trigger this not on app launch, but after the user has done something meaningful: completed onboarding, posted their first item, hit a flow where notifications add value. Show a small explanation screen first. Opt-in rates roughly double when you do this right.

Step 4: store the token against the user

The token belongs to a device, not a user. A user can have multiple devices, and a device can be passed between users (rare, but handled).

The shape that works (using Supabase as an example):

CREATE TABLE push_tokens (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
  token TEXT NOT NULL,
  platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(token)
);

CREATE INDEX push_tokens_user_id_idx ON push_tokens(user_id);
ALTER TABLE push_tokens ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can read their own tokens"
  ON push_tokens FOR SELECT
  USING (auth.uid() = user_id);

CREATE POLICY "Users can insert their own tokens"
  ON push_tokens FOR INSERT
  WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can delete their own tokens"
  ON push_tokens FOR DELETE
  USING (auth.uid() = user_id);

The UNIQUE(token) constraint matters. If a device gets reassigned to a new user, the new user's registration replaces the old one rather than duplicating. Handle the unique violation by updating user_id on conflict.

For Convex, the equivalent is a pushTokens table with similar uniqueness and a registration mutation. We covered that pattern in our Supabase vs Convex comparison.

Step 5: send notifications from your backend

Expo's push API is HTTP-based. Send up to 100 notifications per request.

async function sendPushNotification(userId: string, title: string, body: string, data: Record<string, unknown> = {}) {
  const tokens = await db
    .from('push_tokens')
    .select('token')
    .eq('user_id', userId);

  if (!tokens.data || tokens.data.length === 0) return;

  const messages = tokens.data.map((t) => ({
    to: t.token,
    sound: 'default',
    title,
    body,
    data,
    priority: 'high',
  }));

  const response = await fetch('https://exp.host/--/api/v2/push/send', {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Accept-Encoding': 'gzip, deflate',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(messages),
  });

  const result = await response.json();
  await handleDeliveryResult(result, tokens.data);
}

Handle the response. Expo returns per-message status. Tokens that fail with DeviceNotRegistered are dead and should be deleted.

A notification that opens the home screen wastes the user's intent. The data payload should include the destination, and your app should route to it on tap.

import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import { useNavigation } from '@react-navigation/native';

export function useNotificationHandler() {
  const navigation = useNavigation();

  useEffect(() => {
    const subscription = Notifications.addNotificationResponseReceivedListener((response) => {
      const data = response.notification.request.content.data;
      if (data.screen) {
        navigation.navigate(data.screen as never, data.params as never);
      }
    });

    return () => subscription.remove();
  }, [navigation]);
}

Send data: { screen: 'PostDetail', params: { postId: 'abc' } } from the backend, and tapping the notification routes the user straight to that screen.

Also handle the case where the app was killed and is launched by the notification. Notifications.getLastNotificationResponseAsync() returns the notification that started the app, if any.

Step 7: handle foreground notifications

By default, notifications arriving while the app is open do not show a banner. Configure that explicitly:

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

Place this once at app startup, ideally in your _layout.tsx or root component.

What breaks in production

The list of things that go wrong if you skip this section:

  • Stale tokens accumulate. Your delivery success rate drops over months. Clean up DeviceNotRegistered tokens on every send.
  • Users with multiple devices get notifications on the wrong one. Always send to all of a user's tokens.
  • Notification taps land on the home screen. Always include a screen field in the payload.
  • iOS shows the notification but Android does not, or vice versa. Test on a real device of each platform.
  • The token does not refresh after a reinstall, so the user gets nothing. Re-register the token on every app launch where the user is signed in.
  • Permission was denied once and the app never asks again. Add a settings screen where the user can enable it manually (deep link to the iOS/Android settings page).

Test on real devices, not the simulator

iOS notifications require a real device. Android works on emulators if Google Play Services is installed.

The test loop:

  1. Build and install the app on a real iPhone and a real Android phone.
  2. Sign in with a test user.
  3. Confirm the token landed in push_tokens.
  4. Trigger a notification from your backend manually.
  5. Confirm it arrives, tap it, and lands on the right screen.

Repeat after every Expo SDK upgrade. Notifications are one of the more upgrade-sensitive APIs.

Cost in 2026

Expo's push service is free. APNs and FCM are free. The cost is on your side: backend storage for tokens, occasional cleanup jobs, and the engineering time to maintain the system.

If you outgrow Expo's push service (very high volume), OneSignal and Knock are mature paid alternatives. For most apps, Expo is fine indefinitely.

FAQ

Do I need a paid Apple Developer account to send push notifications?

Yes. The $99/year Apple Developer Program is required for any production push setup on iOS. There is no free path.

Do I need Firebase for Android?

Yes, you need an FCM project, which is free. The google-services.json file from the Firebase console is required for Android builds.

Can I send notifications from a Supabase Edge Function?

Yes. Supabase Edge Functions can call Expo's push API directly. This is the cleanest pattern for Supabase-backed apps.

Can I send notifications from Convex?

Yes. Convex actions can call external HTTP services, including Expo's push API. We use this pattern in our Convex starter.

What if the user disables notifications in the OS settings?

Your token becomes invalid. Detect this on app launch via the permission status and re-prompt politely. Some apps deep link to the OS settings screen as a recovery flow.

Should I use OneSignal instead?

For most teams, no. Expo's push service is simpler, free, and integrated with your stack. OneSignal is worth it when you need advanced segmentation, A/B testing on notification copy, or large-scale automation.

Notifications shouldn't take a week to wire up

Shipnative ships with token registration, server-side delivery, deep link handling, and stale-token cleanup already in place.

Get Started Now

Further reading

Ready to ship faster?

Get lifetime access to Shipnative for a one-time payment of $99.