In the foreground, the handler from expo-notifications runs and addNotificationReceivedListener callbacks fire. In the background or killed, the system shows the notification without your JavaScript. A tap starts or resumes the app, and expo-notifications reports a response, where navigation belongs. useLastNotificationResponse() returns the latest response, including the one that cold-started the app, so a single hook in the root layout covers both cases:
export function useNotifications() {
const router = useRouter();
const last = Notifications.useLastNotificationResponse(); // survives a cold start
// ...
useEffect(() => { // the user tapped a notification
const url = last?.notification.request.content.data?.url;
if (typeof url !== 'string') return;
console.log('tapped:', url, '| action:', last?.actionIdentifier);
router.push(url as Href);
}, [last]);
}RootStack in src/app/_layout.tsx calls it. Let data name a route rather than carry content, so the screen loads fresh data and the URL doubles as a deep link (Deep Links); treat it as untrusted input. Data-only pushes can run a background task (registerTaskAsync with expo-task-manager), but both platforms throttle them.