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.
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:
- 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.
- 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.
- 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.
- 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.
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
dataand 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.