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:
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:
- Loads the user's active devices.
- Groups them by
device.provider. - Sends one batch per provider.
- Folds each provider's results back into the matching
bp_deliveryrows.
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
| Platform | Provider | Token stored on the device row |
|---|---|---|
web | web-push | The browser's Push API subscription, as JSON |
web | fcm | An FCM web registration token |
android | fcm | The device's FCM registration token |
ios | apns | The raw APNs device token (hex) |
ios | fcm | An FCM registration token, when iOS goes through Firebase |
ios, android | expo | An 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. Choosefcmfor 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 -
exporelays 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 -
apnstalks to Apple directly with your p8 key and is the shortest path.fcmfor 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-adminAPNs 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
- FCM Setup - Firebase project, service account, FCM for web.
- APNs Setup - p8 key, topic, sandbox vs production.
- Expo push setup - tickets, receipts, and when to pick it.
- Native / Mobile Testing - real APNs and FCM tokens from an Expo dev-client app.
- Token Lifecycle - dead tokens, rotation, staleness.
Provider interfaces
Prop
Type
Prop
Type
Prop
Type