better-push
Delivery channels

Providers

The provider model, routing by device.provider, and the web/Android/iOS matrix.

A provider is a push transport. better-push ships four of them - web push, FCM, APNs, and Expo - behind one interface, and picks the right one for each device automatically. Your application code never branches on platform.

The provider model

Every transport implements the same PushProvider interface. Its stable key is stored on each device, platforms constrains registration, validateToken performs a cheap synchronous shape check, and send returns one result per payload. Providers such as Expo may also implement receipt checks for outcomes that are not available when the send is first accepted. The generated interface reference is at the end of this page.

A provider never throws for a per-device failure. It returns a normalized ProviderResult per payload carrying a code - one of ok, invalid_token, expired_token, rate_limited, payload_too_large, provider_error, network_error - and a tokenDead flag. That uniformity is what lets a single send record comparable delivery rows across three very different transports. See Token Lifecycle for what tokenDead triggers.

Configuring providers

Pass every transport your app uses in one array:

src/push.ts
import { betterPush } from "@better-push/core";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
import { webPush } from "@better-push/core/providers/web-push";
import { fcm } from "@better-push/core/providers/fcm";
import { apns } from "@better-push/core/providers/apns";
import { db } from "@/db";

export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [
    webPush({
      vapid: {
        subject: "mailto:you@example.com",
        publicKey: process.env.VAPID_PUBLIC_KEY!,
        privateKey: process.env.VAPID_PRIVATE_KEY!,
      },
    }),
    fcm({
      serviceAccount: JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!),
    }),
    apns({
      keyId: process.env.APNS_KEY_ID!,
      teamId: process.env.APNS_TEAM_ID!,
      key: process.env.APNS_KEY!,
      topic: process.env.APNS_BUNDLE_ID!,
      production: process.env.APNS_PRODUCTION === "true",
    }),
  ],
  session: async (request) => getSession(request),
});

Configure only the providers you actually use. Each factory validates its options eagerly and throws INVALID_CONFIG when credentials are missing, so build the array conditionally if some environments only have VAPID keys.

Routing by device.provider

Every row in bp_device carries a platform and a provider. The provider column is written at registration time and is the only thing the send pipeline routes on.

When you call push.notify(), better-push:

  1. Loads the user's active devices.
  2. Groups them by device.provider.
  3. Sends one batch per provider.
  4. Folds each provider's results back into the matching bp_delivery rows.

So a user with a browser, an Android phone, and an iPhone is reached by one call:

await push.notify("orderShipped", {
  userId: user.id,
  payload: { orderId: "1024", eta: "tomorrow" },
});

That is one web push batch, one FCM batch, and one APNs batch, with one delivery row per device. There is no per-platform branching in your code and no second API to learn.

The platform and provider matrix

PlatformProviderToken stored on the device row
webweb-pushThe browser's Push API subscription, as JSON
webfcmAn FCM web registration token
androidfcmThe device's FCM registration token
iosapnsThe raw APNs device token (hex)
iosfcmAn FCM registration token, when iOS goes through Firebase
ios, androidexpoAn ExponentPushToken[...], relayed by Expo

expo() serves ["ios", "android"]; webPush() serves ["web"], apns() serves ["ios"], and fcm() serves ["android", "ios", "web"].

Choosing

  • Web - prefer web-push. It is a standard (RFC 8291/8292), needs no Google account, and works in every browser with the Push API. Choose fcm for web only if you already run the Firebase SDK on the client and want one pipeline. See Web Push Setup and FCM Setup.
  • Android - fcm. There is no alternative transport on Android.
  • Expo - expo relays through Expo's push service to both Apple and Google, with no credentials of your own. It is the right answer for an app distributed with Expo's push credentials, and a third party in the delivery path for everyone else. See Expo push setup.
  • iOS - apns talks to Apple directly with your p8 key and is the shortest path. fcm for iOS routes the same push through Firebase, which still needs the p8 key uploaded to your Firebase project; pick it only if you want a single Firebase pipeline for both mobile platforms.

A browser can hold both a web-push and an fcm registration at once, but then it is two device rows and the user gets the notification twice. Register one provider per client.

Registration rejects impossible pairs

POST {basePath}/devices takes the platform, the provider key, and the token:

{ "platform": "ios", "provider": "apns", "token": "a1b2c3d4e5f6" }

The endpoint resolves the named provider from your config and returns 400 INVALID_BODY when the pair cannot work:

  • an unconfigured provider - the message lists the providers you did configure.
  • a platform the provider does not serve, e.g. { "platform": "android", "provider": "apns" } - the message names the provider and the platforms it serves.
  • a token that fails the provider's validateToken, e.g. a non-hex string for APNs.

This means a client bug can never file an iOS token under the FCM provider and silently break sends months later.

Optional dependencies

firebase-admin is an optional peer dependency, and fcm and apns live on their own subpaths:

import { webPush } from "@better-push/core/providers/web-push";
import { fcm } from "@better-push/core/providers/fcm";
import { apns } from "@better-push/core/providers/apns";

Importing the package root never pulls in Firebase, and the fcm entry imports firebase-admin lazily on its first send. A web-push-only app installs nothing extra. Only apps that call fcm() need:

pnpm add firebase-admin

APNs has no extra dependency at all: the provider speaks APNs HTTP/2 through Node's built-in node:http2 and signs its provider token with jose, which is already a dependency of better-push.

Next

Provider interfaces

Prop

Type

Prop

Type

Prop

Type

On this page