Mobile Apps with React Native and Expo · Device Features, Performance, Testing and Shipping

Push Notifications

Ask for permission at the right moment, get the device's push token, send it to your server, and handle a tapped notification by opening the right screen, including when the app was closed.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

Order updates as push notifications: "Your order has shipped". Tapping one opens that order's screen, whether the app was open, in the background or fully closed.

The idea

The flow has four parts:

  1. Permission. iOS (and Android 13+) asks the user. Ask in context (after they place an order: "Want to know when it ships?"), not on first launch, or many will say no and rarely change it later.
  2. Token. The device gets a push token. With Expo's push service it's an Expo push token; you can also use native APNs/FCM device tokens with your own server setup.
  3. Server. Your backend stores tokens per user (a user can have several devices) and sends notifications through the push service. Remove tokens the service reports as invalid.
  4. Handling. Decide how notifications look when the app is in the foreground, and route taps: put the destination in the notification's data (for example { url: "/orders/123" }) and navigate to it.

Push notifications need a development build or a store build to test fully; Android also needs a notification channel for importance and sound settings.

The code

import { useEffect } from "react";
import { Platform } from "react-native";
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import Constants from "expo-constants";
import { router } from "expo-router";

Notifications.setNotificationHandler({                 // how to show notifications while the app is open
  handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false }),
});

export async function registerForPush(sendToServer: (token: string) => Promise<void>) {
  if (!Device.isDevice) return;                          // simulators can't receive pushes
  if (Platform.OS === "android") {
    await Notifications.setNotificationChannelAsync("orders", { name: "Order updates", importance: Notifications.AndroidImportance.HIGH });
  }
  const current = await Notifications.getPermissionsAsync();
  const status = current.granted ? current : await Notifications.requestPermissionsAsync();
  if (!status.granted) return;                           // respect "no"; offer a settings link later
  const projectId = Constants.expoConfig?.extra?.eas?.projectId;
  const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId });
  await sendToServer(token);
}

export function useNotificationRouting() {
  useEffect(() => {
    const open = (n: Notifications.Notification) => {
      const url = n.request.content.data?.url;
      if (typeof url === "string" && url.startsWith("/")) router.push(url as any);   // only internal paths
    };
    const last = Notifications.getLastNotificationResponse();   // the app was opened by a tap
    if (last) open(last.notification);
    const sub = Notifications.addNotificationResponseReceivedListener((r) => open(r.notification));
    return () => sub.remove();
  }, []);
}

On the server, sending is an HTTP call to the push service with the token, title, body and data. Batch messages and read back the tickets/receipts to find invalid tokens.

Try the logic

Server-side logic decides whether and what to send. This sketch applies user preferences and quiet hours, builds one message per device, and drops tokens the push service said were invalid.

Build push messages: preferences, quiet hours, many devicesTry it yourself
type Prefs = { orderUpdates: boolean; quietStart: number; quietEnd: number; timezoneOffset: number };
type Message = { to: string; title: string; body: string; data: { url: string } };

const devices: Record<string, string[]> = {
  ana: ["ExponentPushToken[aaa]", "ExponentPushToken[bbb]"],
  ben: ["ExponentPushToken[ccc]"],
  cat: [],
};
const prefs: Record<string, Prefs> = {
  ana: { orderUpdates: true, quietStart: 22, quietEnd: 7, timezoneOffset: 1 },
  ben: { orderUpdates: false, quietStart: 22, quietEnd: 7, timezoneOffset: -5 },
  cat: { orderUpdates: true, quietStart: 23, quietEnd: 6, timezoneOffset: 0 },
};
const invalid = new Set(["ExponentPushToken[bbb]"]);    // reported by the push service earlier

function inQuietHours(p: Prefs, utcHour: number): boolean {
  const local = (utcHour + p.timezoneOffset + 24) % 24;
  return p.quietStart > p.quietEnd ? local >= p.quietStart || local < p.quietEnd : local >= p.quietStart && local < p.quietEnd;
}

function orderShipped(user: string, orderId: string, utcHour: number): Message[] | string {
  const p = prefs[user];
  if (!p.orderUpdates) return "opted out";
  if (inQuietHours(p, utcHour)) return "quiet hours: deliver in the morning";
  const tokens = devices[user].filter((t) => !invalid.has(t));
  if (!tokens.length) return "no devices";
  return tokens.map((to) => ({ to, title: "Your order has shipped", body: `Order ${orderId} is on its way`, data: { url: `/orders/${orderId}` } }));
}

for (const [user, hour] of [["ana", 14], ["ana", 22], ["ben", 14], ["cat", 14]] as const) {
  const result = orderShipped(user, "A-1001", hour);
  console.log(`${user} at ${hour}:00 UTC ->`, typeof result === "string" ? result : result.map((m) => `${m.to} ${m.data.url}`).join(", "));
}

Output

ana at 14:00 UTC -> ExponentPushToken[aaa] /orders/A-1001
ana at 22:00 UTC -> quiet hours: deliver in the morning
ben at 14:00 UTC -> opted out
cat at 14:00 UTC -> no devices

Common mistakes

  • Asking for permission on first launch with no context. Ask when the value is obvious.
  • Storing one token per user. People have several devices; store a list and clean it up.
  • Navigating to any URL from notification data. Accept only your own internal paths.
  • Forgetting the cold-start case. A tap that launches the app must be read with getLastNotificationResponse (or the equivalent hook) after navigation is ready.
  • No Android channel. Without one, notifications may be silent or low priority.

Key points

  • Ask permission in context; respect "no".
  • Get the push token, send it to your server, keep a list per user, drop invalid tokens.
  • Put an internal route in data and navigate on tap, including cold starts.
  • Apply preferences and quiet hours on the server.

Further reading: Expo push notifications setup, Sending notifications.

Practice questions

When should an app ask for push notification permission?

Answer

In context, when the benefit is clear (e.g. right after placing an order: 'notify me when it ships'), not at first launch. Users who decline the system prompt rarely re-enable it, so a pre-prompt explaining the value is common.

How do you open the right screen when a user taps a notification while the app is closed?

Answer

Include an internal route in the notification's data, then on startup read the notification response that launched the app (getLastNotificationResponse) and navigate once the router is ready; also listen for taps while the app is running.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?