native-notify
v5.3.0
Published
Expo Push Notifications (React Native) by https://NativeNotify.com. Send your first Expo push notification in less than a minute.
Maintainers
Keywords
Readme
native-notify
You must create a free NativeNotify.com account to receive an App Id and an App Token, or native-notify won't work.
Go to https://NativeNotify.com to sign up for free, no credit card required.
What is Native Notify?
Native Notify is a React Native Expo Push Notification service. Native Notify makes React Native Expo Push Notifications simple. With this native-notify plugin, you can send your first push notification in under 1 minute. Sign up for https://NativeNotify.com for free. No credit card required.
Does native-notify work in Expo managed-workflow?
Yes, native-notify works in Expo managed-workflow or Expo bare-workflow. You do NOT have to eject out of Expo to use native-notify.
Setup Guide:
Step 1: Install
npm i native-notify
npx expo install expo-device expo-notifications expo-constantsexpo-notifications also needs its config plugin in app.json — required for push notifications in development builds and EAS builds:
{
"expo": {
"plugins": ["expo-notifications"]
}
}Expo Go on Android (SDK 53+): push tokens are not available in Expo Go — use a development build (
npx expo run:androidor an EAS dev build). The Notification Inbox still works in Expo Go withmode="indie".
Step 2: Import
Import registerNNPushToken in your App.js file:
import registerNNPushToken from 'native-notify';Step 3: Make sure your App.js function is a hook function
Your App.js function MUST be a hook function, or your push notifications will NOT work. Here is an example:
export default function App() {
...
}This link explains how hooks work: https://reactjs.org/docs/hooks-intro.html
Step 4: Paste
Paste this code into your App.js component in the App function:
registerNNPushToken(yourAppId, 'yourAppToken');You must go to https://NativeNotify.com to receive a free App Id and App Token, or the registerNNPushToken function will not work. It's free to sign up. No credit card required.
Example of an App.js component with native-notify code included:
import registerNNPushToken from 'native-notify';
export default function App() {
registerNNPushToken(yourAppId, 'yourAppToken');
return (
...
)
}Optional: configure your credentials once
Instead of passing appId / appToken to every call, set them once — with a provider:
import { NativeNotifyProvider } from 'native-notify';
export default function App() {
return (
<NativeNotifyProvider appId={yourAppId} appToken="yourAppToken">
<Root />
</NativeNotifyProvider>
);
}or at module scope (works for the plain, non-React functions):
import { NativeNotify } from 'native-notify';
NativeNotify.init({ appId: yourAppId, appToken: 'yourAppToken' });Every function still accepts explicit ids, so nothing changes if you keep passing them.
Registration callbacks & token rotation
registerNNPushToken(yourAppId, 'yourAppToken', {
onRegistered: (result) => console.log('native-notify ready:', result.expoPushToken),
onError: (error) => console.warn('native-notify registration failed:', error),
});Token registration retries once on failure, has a 10s timeout, and automatically re-registers when the device push token rotates (Android reinstall / applicationId change, iOS backup restore). On Android in Expo Go you get a clear log message instead of a cryptic native error. You can also run the raw flow yourself — const result = await registerForPushNotificationsAsync(); — it never throws, it reports ({ status, reason, expoPushToken, ... }).
Paired-token registration (v5.3.0+)
One device can hold both an Expo push token and a native (FCM / APNs) push token, and registration now tells the server they belong to the same device by sending them together as one optional pair:
{
"expoToken": "ExponentPushToken[...]", // the device's Expo push token
"nativeToken": "<fcm-or-apns-token>", // the same device's native push token
"platform": "android" | "ios"
}This is sent automatically by registerNNPushToken (as devicePair on POST /api/device/tokens) and by registerIndieID (as devicePair on POST /api/indie/id) — you do not have to do anything. It is purely additive:
- Older servers ignore the field; the request still carries the tokens it always carried.
- Older SDK versions keep working; the server infers the same pair from the
expoToken+deviceTokena single registration already sends. - A device that is missing one of the two tokens sends a body identical to previous versions (the field is simply omitted).
The server uses the pair to deliver one copy of group / topic-group / follow pushes to a device that is registered with both token kinds (previously such a device received two copies of the same notification). The Expo copy is only skipped once the app's native push credentials have recently passed their live check; anything uncertain keeps the previous behavior. You can also build the pair yourself with the exported buildDevicePair(platform, expoToken, nativeToken) / withDevicePair(...) helpers.
Use
The registerNNPushToken function will register your user's Native Notify push notification token and will return a data object. You can then send your users push notifications in the https://NativeNotify.com push notification portal. You can send data objects with your Native Notify push notifications. Once a user taps on your Native Notify push notification, the value of the data object will be returned to the pushDataObject variable. You can use this value to do things like redirect your users to a particular screen once a Native Notify push notification is tapped.
Push data & taps (cold starts included)
import { useNativeNotifyPress } from 'native-notify';
export default function App() {
const { data } = useNativeNotifyPress<{ url?: string }>();
useEffect(() => {
if (data?.url) router.push(data.url); // or navigation.navigate(...)
}, [data]);
}useNativeNotifyPress handles both the tap that launched a cold app (which the raw expo-notifications response listener misses) and taps while the app runs. getPushDataObject() is a drop-in wrapper over it. Convention: put a url key in your push data to deep-link with Expo Router / React Navigation.
Analytics (opt-in)
native-notify can report app analytics to your Native Notify dashboard: screen views ("which screens do users use most"), sessions, and per-notification open rates. Everything is off by default — enable the flags you want:
import { NativeNotify, useNativeNotifyScreenTracking } from 'native-notify';
NativeNotify.init({
appId: yourAppId,
appToken: 'yourAppToken',
analytics: {
screens: true, // screen views
sessions: true, // foreground session length
opens: true, // notification tap → open rate
deviceId: true, // stable device id so uniques survive token rotation
},
});Screen tracking, two ways:
// Expo Router — one line in the root layout tracks every route change:
export default function RootLayout() {
useNativeNotifyScreenTracking(); // reads usePathname() for you
return <Slot />;
}
// Manual (any navigation library):
import { trackScreen } from 'native-notify';
trackScreen('Home');
trackScreen(`/products/${id}`);- Screen views are batched (flushed ~2s later, immediately on background) and consecutive duplicates collapse.
sessionsis wired automatically byregisterNNPushToken;useNativeNotifySessionTracking()exists for manual setups.opensreads thenn_notification_idNative Notify injects into every push payload (mass + single indie sends; group sends carry no per-notification id) and reports the tap for per-notification open rates. Taps that surface twice (listener + cold start) are deduped.deviceIdusesexpo-application(getIosIdForVendorAsync/getAndroidId) when installed — unique user counts survive Expo-token rotation. Without it, uniques fall back to the Expo push token.- Analytics requests are best-effort: a failure never throws into your app, and your own
pushDatakeys are untouched (the server addsnn_notification_id/nn_sourcealongside them).
Notification Inbox (prebuilt components)
native-notify ships a drop-in Notification Inbox: a bell icon for your header with a red dot when there are unread notifications, and a full-screen inbox screen that opens when it's tapped.
import { NotificationInboxBell } from 'native-notify';
// Expo Router / React Navigation header:
options={{
headerRight: () => (
<NotificationInboxBell
appId={yourAppId}
appToken="yourAppToken"
mode='indie' // or 'mass' (default)
subId={currentUser.id} // required when mode='indie'
/>
),
}}Modes
| mode | What it shows | Notes |
| --- | --- | --- |
| mass (default) | App-wide notifications | Read state uses the device's Expo push token — works on real devices, Android emulators with Google Play services, and iOS Simulators on Xcode 14+. On web it simply never shows a dot. On Android, use a development build — Expo Go can't mint push tokens (SDK 53+). |
| indie | Per-user notifications | Requires subId. Works in Expo Go, on simulators, and on web. |
Opening the inbox marks the fetched notifications as read on the server (existing API behavior), so the red dot clears when it opens. The unread count is also synced to the app icon badge (opt out with syncBadge={false}). The Delete button is only shown in indie mode — mass deletion is an admin action that removes a notification for every user, so it is intentionally not exposed.
Want per-row read state instead? Pass perNotificationRead — pages are fetched with each row's real read flag (indie via ?perNotification=true; mass via this device's Expo push token), unread rows get a small dot, and each row is marked read as it is opened. Existing behavior stays the default.
Props
NotificationInboxBell and NotificationInboxScreen (plus the headless useNotificationInbox hook):
| prop | default | description |
| --- | --- | --- |
| appId | — | Your NativeNotify App ID (or set it once with NativeNotifyProvider / NativeNotify.init) |
| appToken | — | Your NativeNotify App Token (same) |
| mode | 'mass' | 'mass' or 'indie' |
| subId | — | Required when mode='indie' |
| take | 20 | Page size |
| syncBadge | true | Sync the unread count to the app icon badge |
| perNotificationRead | false | Per-row unread dots + mark-on-open (read flag per row instead of marking the whole inbox read on fetch) |
| colors | theme defaults | Partial override, e.g. { dot: '#EF4444', accent: '#2563EB' } |
| title | 'Notifications' | Header title |
| emptyText | "You're all caught up" | Empty-state body text |
| allowDelete | true (indie only) | Hide the per-row delete button in indie mode |
| onNotificationPress | — | (notification) => {} called when a row is tapped |
| showCount | false | Show a numeric badge instead of a plain dot |
| maxCount | 99 | Badge cap ("99+") |
| renderIcon | bundled bell | ({ unreadCount, color }) => node custom bell icon |
| iconSize / iconStyle / containerStyle | — | Styling escape hatches |
| onOpen | — | If set, the bell calls this instead of opening the built-in screen |
Colors follow the device's light/dark mode automatically; every key is overridable via colors (icon, dot, badgeText, background, headerBackground, title, text, mutedText, border, card, accent, delete, emptyTitle, emptyText).
Want your own trigger? Render NotificationInboxScreen and control visible / onClose yourself, or build a fully custom UI on the useNotificationInbox({ appId, appToken, mode, subId, take, syncBadge }) hook.
Exact pagination is available for custom UIs: getNotificationInboxPage() and getIndieNotificationInboxPage() return { rows, total } (the server's X-Total-Count), so "load more" is exact instead of a guess.
Per-notification read state is available for custom UIs too: pass { perNotification: true } as the last argument to either page function to get each row's real read boolean (indie adds ?perNotification=true; mass adds this device's ?expoToken=) and skip the legacy "mark everything read on fetch" — then call markMassNotificationRead(notificationId) / markIndieNotificationRead(notificationId, subId) as rows are opened (pass the row's notification_id; without one they resolve false and send nothing, since the endpoint's id-less form marks the whole inbox read).
Sending notifications (rich fields)
The send helpers send through your Native Notify account — the same messages the dashboard and the hosted MCP server can send, with the modern Expo message fields passed straight through to every path (mass, indie, group, followers). Sends are live and cannot be recalled.
import { sendMassNotification, sendIndieNotification } from 'native-notify';
// Every registered device (mass):
await sendMassNotification('Service update', 'We are back online.', {
pushData: { url: '/status' },
subtitle: 'All systems normal',
channelId: 'alerts', // Android channel (create it first — below)
interruptionLevel: 'active', // iOS
sound: 'chime.wav',
});
// One subscriber (indie):
await sendIndieNotification(userId, 'Your order', 'It shipped!', {
pushData: { url: '/orders/' + orderId },
badge: 1,
categoryId: 'ORDER_UPDATE',
});
// A custom audience, or the followers of a follow-master:
await sendIndieGroupNotification(subIds, 'Group news', 'Hello all', { channelId: 'news' });
await sendNotificationToFollowers(masterSubId, 'New post', 'Read it', { mutableContent: true });Every send helper takes the same options object: pushData, the rich Expo fields — subtitle, badge, ttl, interruptionLevel ('passive' | 'active' | 'timeSensitive' | 'critical'), categoryId, channelId, collapseId, contentAvailable, mutableContent, sound (false = silent push) — plus bigPictureURL (Android big picture / iOS attachment source) and optional appId / appToken overrides. Unset fields keep the server defaults, and the request body carries only what you set (sound: false and badge: 0 are values, not omissions). Each helper resolves with the server's response body — "Success!" when the push went out — and throws on an error status (e.g. 400 for a missing title or an invalid field). A few non-deliveries are answered with 201 and an explanation instead of an error (a trial-expired account, an indie subID that isn't registered, a follow-master with no followers), so check the body rather than treating every resolve as delivered. sendIndieGroupNotification also throws up front when the subIDs array is empty.
Create the Android channels your channelId values refer to (a thin wrapper over expo-notifications' setNotificationChannelAsync):
import * as Notifications from 'expo-notifications';
import { setAndroidNotificationChannel } from 'native-notify';
await setAndroidNotificationChannel('alerts', {
name: 'Alerts',
importance: Notifications.AndroidImportance.HIGH,
sound: 'chime.wav',
vibrationPattern: [0, 250, 250, 250],
});Resolves true on Android; false on iOS/web (Android channels do not exist there) or on failure — it never throws.
Upgrading
Unreleased (additive, next 5.x) — server-capability wave. New: per-notification inbox read state (perNotificationRead on the inbox components + hook, { perNotification: true } on getNotificationInboxPage / getIndieNotificationInboxPage, markMassNotificationRead / markIndieNotificationRead), rich-field send helpers (sendMassNotification, sendIndieNotification, sendIndieGroupNotification, sendNotificationToFollowers, setAndroidNotificationChannel) and their types (RichPushFields, SendNotificationOptions, AndroidNotificationChannelOptions, PerNotificationReadOptions; useNotificationInbox() also returns perNotificationRead + markNotificationRead). No breaking changes — every existing call still makes its exact same request.
Fixes in the same release: analytics reports (screens, sessions, opens) now also work when the ids are only passed to registerNNPushToken(appId, appToken, { analytics }) — 5.1 read them from NativeNotify.init() only and silently dropped every report otherwise (NativeNotify.init() ids still win). The flags and ids from that call now also apply before components under it mount, so a cold-start tap read by useNativeNotifyPress() or the first screen tracked by useNativeNotifyScreenTracking() in a child component is no longer dropped. The reader form useNativeNotifyScreenTracking(() => currentRouteName) (React Navigation or any navigator other than Expo Router) now tracks every screen change — 5.1 tracked only the first screen; and sendMassNotification stamps the inbox dateSent (device-local M-D-YYYY H:MMAM, like the dashboard) so its inbox rows carry a date.
v5.1.1 — fixes an infinite re-registration loop: the token-rotation listener could re-fetch the device push token, which re-fires the listener (expo-notifications documents the footgun) — a stuck app would hammer /api/device/tokens thousands of times per second. The listener now skips same-token no-op events, throttles to at most one check per minute, and only re-posts when the tokens actually changed. No API changes.
v5.1 (from v5.0) — additive: the opt-in analytics features (analytics flags on NativeNotify.init and registerNNPushToken options; trackScreen, flushScreenQueue, useNativeNotifyScreenTracking, startSessionTracking, endSessionTracking, startSessionAutoTracking, useNativeNotifySessionTracking, reportNotificationOpen, getStableDeviceKey, getRegistrationMeta, setAnalyticsPushToken, configureAnalytics). No breaking changes.
v5 (from v4) — new exports: NativeNotify, NativeNotifyProvider, useNativeNotify, useNativeNotifyPress, registerForPushNotificationsAsync, getNotificationInboxPage, getIndieNotificationInboxPage; registerNNPushToken gains an optional third options argument; appId/appToken are optional everywhere (resolved from NativeNotify.init / <NativeNotifyProvider> when omitted).
One breaking change: the five follow helpers — registerFollowMasterID, registerFollowerID, postFollowingID, unfollowMasterID, updateFollowersList — return a structured NativeNotifyActionResult ({ success, status, message, error }) instead of a plain string. message contains the old text; network failures now surface as status: 'error' with the underlying error instead of being reported as "already registered".
Show your support
Give a ⭐️ if this project helped you!
