Skip to content

Notifications

Remind the user at the right moment, reach them with push when the app is closed, and know which notification they tapped. @symbiote-native/notifications wraps expo-notifications (permissions, device and Expo push tokens, scheduling, presentation, badges, Android channels, iOS and Android categories, and the background notification task hook) for every SymbioteNative adapter.

Every core export is a plain async function or a module-level listener registration, shared by all adapters. One adapter-specific binding, useLastNotificationResponse, is ported to all five.

OS platform Support
iOS ✅ live
Android ✅ live
Framework adapter Support
React ✅ live
Vue ✅ live
Angular ✅ live
Svelte ✅ live
Solid ✅ live
Terminal window
npm install @symbiote-native/notifications

Scaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --notifications (or add --notifications in an existing app) installs and wires this for you — see @symbiote-native/cli.

expo-notifications and expo-modules-core come along as regular, pinned dependencies — never install either yourself, and never add the expo meta-package to your project.

Permissions, scheduling, presentation, badges, channels, and categories all work with zero extra app-level native config beyond the linker step above.

Push notifications need real, app-specific native configuration

Section titled “Push notifications need real, app-specific native configuration”
Platform What you must add yourself Why
Android A real Firebase project + google-services.json, plus the Google Services Gradle plugin Push delivery is FCM, not a generic APNs-style relay
Android A 96×96 all-white PNG at res/drawable-*dpi/notification_icon.png, plus two <meta-data> entries on <application> (com.google.firebase.messaging.default_notification_icon, expo.modules.notifications.default_notification_icon), both @drawable/notification_icon The status-bar icon Android draws for a notification with no custom small icon
Android Optionally, android:color via @color/notification_icon_color and the matching default_notification_color meta-data entries Tint applied to the small icon above
Android Optionally, com.google.firebase.messaging.default_notification_channel_id meta-data Which channel an FCM-delivered notification lands in when the payload names none
Android Copy any custom sound file into res/raw/ INotificationContentInput.sound on iOS; Android channels carry their own sound field — see setNotificationChannelAsync
iOS The Push Notifications capability + aps-environment entitlement (development/production) in Xcode getDevicePushTokenAsync/getExpoPushTokenAsync need APNs registration, which needs this entitlement — omitting it fails registration silently
iOS Add any custom sound file as a bundle resource in Xcode Same as Android’s res/raw/ step
iOS Optionally, UIBackgroundModes including remote-notification in Info.plist Lets a background/silent push wake the app to run a registered task
Both @symbiote-native/task-manager installed, with a task defineTask’d at module scope before registerTaskAsync runs registerTaskAsync/unregisterTaskAsync here need it linked to work at all
import * as Notifications from '@symbiote-native/notifications';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: false,
shouldSetBadge: false,
}),
});
const { granted } = await Notifications.requestPermissionsAsync();
if (granted) {
const identifier = await Notifications.scheduleNotificationAsync({
content: { title: "Time's up!", body: 'Change sides!' },
trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: 60 },
});
const subscription = Notifications.addNotificationReceivedListener(notification => {
console.log(notification.request.content.title);
});
// later: subscription.remove();
await Notifications.cancelScheduledNotificationAsync(identifier);
}

Identical import surface on every adapter: @symbiote-native/notifications/react, /vue, /svelte, /solid, /angular all re-export the same functions.

useLastNotificationResponse returns the notification the user last tapped, so a screen can open the right content, including when the tap launched the app. It is undefined until the first read, then the response, or null when there is none.

import { useLastNotificationResponse } from '@symbiote-native/notifications/react';
export default function Inbox() {
const response = useLastNotificationResponse();
return <text>{response?.notification.request.content.title ?? 'no tap yet'}</text>;
}
const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId });

projectId is required. applicationId and the iOS push environment default from @symbiote-native/application. Use getDevicePushTokenAsync() to get the raw APNs or FCM token instead.

// permissions
getPermissionsAsync(): Promise<INotificationPermissionsStatus>
requestPermissionsAsync(permissions?: INotificationPermissionsRequest): Promise<INotificationPermissionsStatus>
// tokens
addPushTokenListener(listener: IPushTokenListener): EventSubscription
getDevicePushTokenAsync(): Promise<IDevicePushToken>
getExpoPushTokenAsync(options?: IExpoPushTokenOptions): Promise<IExpoPushToken>
setAutoServerRegistrationEnabledAsync(enabled: boolean): Promise<void>
unregisterForNotificationsAsync(): Promise<void>
subscribeToTopicAsync(topic: string): Promise<null> // android
unsubscribeFromTopicAsync(topic: string): Promise<null> // android
// presentation
getPresentedNotificationsAsync(): Promise<INotification[]>
dismissNotificationAsync(notificationIdentifier: string): Promise<void>
dismissAllNotificationsAsync(): Promise<void>
// badge
getBadgeCountAsync(): Promise<number>
setBadgeCountAsync(badgeCount: number): Promise<boolean>
// scheduling
scheduleNotificationAsync(request: INotificationRequestInput): Promise<string>
getAllScheduledNotificationsAsync(): Promise<INotificationRequest[]>
cancelScheduledNotificationAsync(identifier: string): Promise<void>
cancelAllScheduledNotificationsAsync(): Promise<void>
getNextTriggerDateAsync(trigger: ISchedulableNotificationTriggerInput): Promise<number | null>
// categories
getNotificationCategoriesAsync(): Promise<INotificationCategory[]>
setNotificationCategoryAsync(identifier, actions, options?): Promise<INotificationCategory>
deleteNotificationCategoryAsync(identifier: string): Promise<boolean>
// channels — android only (no-op returning []/null/void elsewhere)
getNotificationChannelsAsync(): Promise<INotificationChannel[]>
getNotificationChannelAsync(channelId: string): Promise<INotificationChannel | null>
setNotificationChannelAsync(channelId, channel): Promise<INotificationChannel | null>
deleteNotificationChannelAsync(channelId: string): Promise<void>
getNotificationChannelGroupsAsync(): Promise<INotificationChannelGroup[]>
getNotificationChannelGroupAsync(groupId: string): Promise<INotificationChannelGroup | null>
setNotificationChannelGroupAsync(groupId, group): Promise<INotificationChannelGroup | null>
deleteNotificationChannelGroupAsync(groupId: string): Promise<void>
// handler / emitter
setNotificationHandler(handler: INotificationHandler | null): void
addNotificationReceivedListener(listener): EventSubscription
addNotificationsDroppedListener(listener): EventSubscription // android
addNotificationResponseReceivedListener(listener): EventSubscription
getLastNotificationResponse(): INotificationResponse | null
clearLastNotificationResponse(): void
addNotificationResponseClearedListener(listener): EventSubscription
DEFAULT_ACTION_IDENTIFIER: string
// background task — needs @symbiote-native/task-manager, see native setup above
registerTaskAsync(taskName: string): Promise<null>
unregisterTaskAsync(taskName: string): Promise<null>

Plus the enums IosAlertStyle, IosAllowsPreviews, IosAuthorizationStatus, AndroidNotificationVisibility, AndroidAudioContentType, AndroidImportance, AndroidAudioUsage, AndroidNotificationPriority, SchedulableTriggerInputTypes, BackgroundNotificationTaskResult, PermissionStatus, and the full content/trigger/payload type surface.

Adapter Entry point Returns
React useLastNotificationResponse() The last response, null or undefined
Vue useLastNotificationResponse() A ref of the same
Svelte useLastNotificationResponse() An object with a reactive current
Solid createLastNotificationResponse() An accessor of the same
Angular inject(LastNotificationResponseService).connect() A signal of the same

The response is deduplicated by notification identifier and kept in sync with addNotificationResponseReceivedListener and addNotificationResponseClearedListener.

  • Token resync is explicit. Upstream re-sends a rolled device token to Expo’s push backend with exponential backoff. Here that is installPushTokenAutoRegistration(). It is idempotent and is installed by setAutoServerRegistrationEnabledAsync(true) (so also by getExpoPushTokenAsync). Call it once at app startup too, so a token that rolled while the app was closed is re-sent. It is explicit rather than a module-load side effect because Metro’s inlineRequires skips a barrel re-export that nothing names as a value.
  • projectId must be passed. Upstream reads it from expo-constants; this package has no access to it.
  • The deprecated getLastNotificationResponseAsync and clearLastNotificationResponseAsync are ported as thin wrappers over the synchronous forms, exactly like upstream.
  • Push does not work on emulators or simulators (see Common questions below). Test remote push on a physical device. Local notifications work everywhere.
  • Android push is FCM. It needs your own Firebase project (see the table above).
  • setBadgeCountAsync takes no options here. Upstream’s web-only badgin option bag is not ported; this project targets iOS and Android.
  • Android channel/channel-group functions branch on platform at runtime rather than shipping as separate iOS/Android builds — off Android they resolve to the documented no-op values ([]/null/void) without touching native.

getExpoPushTokenAsync fails or I never get a token. Pass your projectId, since this package cannot read it from the Expo config. On Android, create a notification channel first: setNotificationChannelAsync must run before getDevicePushTokenAsync or getExpoPushTokenAsync, because Android 13’s permission prompt does not appear until a channel exists. Android also needs your FCM credentials and google-services.json (see the table above).

A notification arrives but nothing shows while the app is open. Foreground notifications need a handler. Call setNotificationHandler with shouldShowBanner and shouldShowList set to true.

Pushes arrive silently or late on Android. Check the notification channel: one created with low importance is silent. Create it with importance: AndroidImportance.HIGH, and send the push with priority: 'high'. Battery optimization can still delay normal-priority messages.

Push never arrives when the app is closed. Confirm a token was registered on the server and, for Android, that the project has valid FCM V1 credentials. Test on a physical device: push does not work on emulators or simulators.

Which notification did the user tap? Use useLastNotificationResponse (see above), or addNotificationResponseReceivedListener.

Do I need push to show a reminder? No. Scheduled local notifications work with no Firebase or APNs setup.

Sources: Expo docs: Notifications, Expo docs: push notifications setup, expo/expo#49638 response listener does not fire for a foreground tap on Android, expo/expo#9745 FCM notifications not received in the foreground, DEV: basics and caveats of expo-notifications.