better-push
Delivery channels

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-store

expo-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

App.tsx
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>
  );
}
  • baseURL is required here. A browser can fall back to "same origin"; a phone has no origin to fall back to.
  • basePath defaults to "/api/push" and must match your server's.
  • getToken is 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. Returning null sends the request unauthenticated and your server answers 401.

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:

src/push.ts
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"
TokengetDevicePushTokenAsync()getExpoPushTokenAsync()
Registers asios/apns, android/fcmios or android /expo
Server providerapns(), fcm()expo()
Delivery pathyour server to Apple/Googleyour server to Expo to Apple/Google
Credentialsyour p8 key, your service accountnone, 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:

SeamWebNative
RequestAuthorizercredentials: "include"Authorization: Bearer …
Lifecycledocument.hidden + visibilitychangeAppState

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:

metro.config.js
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.

On this page