React Native
Register device tokens, read the in-app feed, and edit preferences from an Expo app with @better-push/core/native.
@better-push/core/native is the React Native client: the same hooks as
@better-push/core/react, returning the same types, bound to
React Native instead of the DOM.
It is a subpath of better-push, not a separate package. Client and server
share a wire protocol, so shipping them as one version means a mismatch is
impossible - there is no native@1.2 to point at a server@1.0.
Hooks only
The headless components (NotificationBell, NotificationInbox,
NotificationPreferences) render DOM elements and are web-only. On native you
build the screens; the hooks give you the state.
Install
npm install @better-push/core expo-notifications
# optional: persists the device id across launches
npm install expo-secure-storeexpo-notifications, expo-secure-store, and react-native are optional
peer dependencies. Nothing on the server pulls them in, and without
expo-secure-store the device id simply lives in memory for the session.
Wrap your app
import { BetterPushProvider } from "@better-push/core/native";
export default function App() {
const [token, setToken] = useState<string | null>(null);
return (
<BetterPushProvider
baseURL="https://app.example.com"
auth={{ getToken: () => token }}
>
<Screens />
</BetterPushProvider>
);
}baseURLis required here. A browser can fall back to "same origin"; a phone has no origin to fall back to.basePathdefaults to"/api/push"and must match your server's.getTokenis called before every request and may be async, so a token kept in secure storage does not have to be mirrored into React state first. Returningnullsends the request unauthenticated and your server answers401.
Every hook also accepts baseURL / basePath for a one-off override, so the
hook shape matches the web exactly - you just do not repeat the configuration.
Authentication
Native auth is a bearer header, not a cookie. Your session resolver
receives the raw Request, so accept both:
session: async (request) => {
const bearer = request.headers.get("authorization")?.replace(/^Bearer /, "");
const user = bearer
? await getUserFromToken(bearer)
: await getUserFromCookie(request);
return user ? { userId: user.id } : null;
},No other server change is needed. POST /devices already accepts
web | ios | android, and the feed, preference, and read routes are transport
agnostic.
Register the device
import { usePushRegistration } from "@better-push/core/native";
function EnableNotifications() {
const { status, error, register, unregister } = usePushRegistration();
return (
<Pressable onPress={() => register({ deviceName: "Alice's iPhone" })}>
<Text>{status === "subscribed" ? "Enabled" : "Enable notifications"}</Text>
</Pressable>
);
}register() requests permission, creates the Android channel, reads the token,
posts it, and stores the returned device id. status is the same union the web
hook uses: unsupported | idle | registering | subscribed | denied | error.
Call it from a tap. Asking for notification permission on launch is the fastest route to a permanent denial, so the hook never prompts on its own.
Raw tokens or Expo tokens
mode decides which token is registered. It defaults to "raw".
mode: "raw" (default) | mode: "expo" | |
|---|---|---|
| Token | getDevicePushTokenAsync() | getExpoPushTokenAsync() |
| Registers as | ios/apns, android/fcm | ios or android /expo |
| Server provider | apns(), fcm() | expo() |
| Delivery path | your server to Apple/Google | your server to Expo to Apple/Google |
| Credentials | your p8 key, your service account | none, or one Expo access token |
const registration = usePushRegistration({
mode: "expo",
projectId: Constants.expoConfig?.extra?.eas?.projectId,
});projectId is required in a bare or EAS build: a token minted against the wrong
project registers successfully and then silently never delivers.
Raw is the default because it keeps a third party out of the delivery path.
Expo is the right answer when your app is distributed with Expo's push
credentials - and it is the only mode where a push delivery can reach
delivered, because Expo is the only transport here that reports receipts. See
Expo push setup and
Native / Mobile Testing.
Both modes can be live at once on the server: a device row names its own provider, so one backend serves an app that registers either.
Unregister before switching
The two modes register different tokens. Switching without unregistering leaves the previous device row behind, and the user gets both.
The feed
import { useNotificationFeed } from "@better-push/core/native";
function Inbox() {
const feed = useNotificationFeed({ markReadOnPushClick: true });
return (
<FlatList
data={feed.notifications}
keyExtractor={(item) => item.id}
refreshing={feed.isLoading}
onRefresh={() => void feed.refresh()}
onEndReached={() => void feed.loadMore()}
renderItem={({ item }) => (
<Pressable onPress={() => void feed.markRead(item.id)}>
<Text>{item.title}</Text>
</Pressable>
)}
/>
);
}Identical return value to the web hook: notifications, unreadCount,
isLoading, isLoadingMore, error, hasMore, loadMore, markRead,
markAllRead, refresh.
markReadOnPushClick is opt-in, exactly as on the web - opening a push is not
universally "read". When it is on, the hook handles both tap paths: a tap while
the app is running, and the tap that cold-started it (which no listener can
catch, because the app did not exist yet).
Polling pauses while the app is backgrounded and refreshes on return, using
React Native's AppState in place of the browser's page visibility.
Preferences
import { usePreferences } from "@better-push/core/native";
function Settings() {
const { preferences, setPreference, save, dirty, isSaving } = usePreferences();
// ... render a Switch per channel, then a Save button
}Same semantics as the web hook: edits are local, save() PUTs only the diff and
adopts the server's resolved view. See Preferences.
How it stays one client
The web and native hooks are not two implementations. The hook bodies live in a shared core and the platforms inject two things:
| Seam | Web | Native |
|---|---|---|
RequestAuthorizer | credentials: "include" | Authorization: Bearer … |
Lifecycle | document.hidden + visibilitychange | AppState |
Pagination, cursors, polling and backoff, optimistic mark-read, and the
preference diff are written once. Anything genuinely platform-shaped - the
service worker on web, expo-notifications on native - stays in the binding
rather than widening the seams.
Metro resolution
The ./native subpath declares a react-native export condition, so Metro
resolves the React Native build and never walks into server code. In a pnpm
monorepo, point Metro at the workspace root:
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
module.exports = config;Leave hierarchical lookup enabled: under pnpm a package's own dependencies live
beside it inside .pnpm, and only the walk up the tree finds them.
A working app
apps/native in the repo is a full consumer: provider, registration, an inbox
screen, and a preferences screen, all from these hooks. Its README has the
credentials, the dev-client build, and the on-device walkthrough.