better-push
Delivery channels

FCM setup

Firebase project, service account, FCM for web, and Android device tokens.

Firebase Cloud Messaging is the transport for Android, and an alternative transport for web and iOS. This page covers the Firebase project, the server credential, the browser client, and where Android tokens come from.

1. Create a Firebase project

  1. Open the Firebase console and create a project (or reuse one you already have).
  2. Under Project settings → Cloud Messaging, confirm the Firebase Cloud Messaging API (V1) is enabled. better-push uses the V1 API exclusively; the deprecated legacy server key is not used anywhere.

2. Create the server credential

The server side authenticates with a service account, not with a client key.

  1. Project settings → Service accounts → Generate new private key.
  2. Firebase downloads a JSON file. Treat it as a secret: it can send push to every device in the project.
  3. Store the whole file as one environment variable, e.g. FIREBASE_SERVICE_ACCOUNT.

firebase-admin is an optional peer dependency, so install it in any app that uses the FCM provider:

pnpm add firebase-admin

Then add the provider:

src/push.ts
import { fcm } from "@better-push/core/providers/fcm";

fcm({
  serviceAccount: JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT!),
});

serviceAccount accepts a parsed object or the raw JSON string, so this is equivalent and saves you the JSON.parse:

fcm({ serviceAccount: process.env.FIREBASE_SERVICE_ACCOUNT! });

An invalid JSON string throws INVALID_CONFIG at construction rather than failing on the first send. The provider initializes its own named Firebase app, so it never collides with a Firebase app your own code already created.

Never ship the service account to the client

The service-account JSON is a server-only secret. The browser needs the client config and the web VAPID key from step 3 - both public values - and nothing else.

3. FCM for web

Web push through Firebase needs three things in the browser: the client config, the web VAPID key, and Firebase's own messaging service worker.

Client config and the web VAPID key

  1. Project settings → General → Your apps → Web app gives you the config object (apiKey, projectId, messagingSenderId, appId, ...). These are public identifiers, safe to expose.
  2. Project settings → Cloud Messaging → Web configuration → Web Push certificates gives you the web VAPID key pair. Copy the public key - it is the vapidKey argument to getToken. This is a Firebase key pair, not the one you generated for the web-push provider; the two are unrelated.

The messaging service worker

Firebase requires its own service worker, served from the root of your origin and named exactly firebase-messaging-sw.js:

public/firebase-messaging-sw.js
importScripts(
  "https://www.gstatic.com/firebasejs/12.0.0/firebase-app-compat.js",
);
importScripts(
  "https://www.gstatic.com/firebasejs/12.0.0/firebase-messaging-compat.js",
);

firebase.initializeApp({
  apiKey: "...",
  projectId: "...",
  messagingSenderId: "...",
  appId: "...",
});

firebase.messaging();

Match the firebasejs version in those URLs to the firebase package you installed. This worker is separate from better-push's public/sw.js, which serves the web-push provider; the two can coexist on one origin.

The filename is not a convention, it is a lookup

getToken() registers /firebase-messaging-sw.js by default. If the file is missing, renamed, or nested in a subdirectory, token retrieval fails with a registration error and no push ever arrives.

Get a token and register the device

src/firebase-push.ts
import { initializeApp } from "firebase/app";
import { getMessaging, getToken } from "firebase/messaging";

const app = initializeApp({
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID!,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
});

export async function registerFcmWeb() {
  if ((await Notification.requestPermission()) !== "granted") return;

  const token = await getToken(getMessaging(app), {
    vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY!,
  });
  if (!token) return;

  await fetch("/api/push/devices", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ platform: "web", provider: "fcm", token }),
  });
}

Call it from a button click, never on load - the permission prompt must follow a user gesture, exactly as with web push.

4. Android

Android device tokens come from a native app, not from a browser.

  1. Project settings → Your apps → Add app → Android, using the same package name as the app.
  2. Download google-services.json and place it where the native build expects it. For the Expo app in this repo that is apps/native, wired through app.json.
  3. On the device, Notifications.getDevicePushTokenAsync() returns the raw FCM registration token, which is registered as { platform: "android", provider: "fcm", token }.

The full walkthrough is in Native / Mobile Testing.

What the provider sends

For each device in the FCM batch, better-push builds one message:

  • notification: { title, body } from the notification.
  • data - your data object with every value stringified, because FCM only accepts string values. Non-string values are JSON.stringifyd.
  • webpush.fcmOptions.link - set from data.url when it is a string, so a click on a web notification opens the right page.

Messages go out with sendEach, which returns one result per message in order. Failures are mapped to normalized codes and recorded per delivery, never thrown. registration-token-not-registered and invalid-argument disable the device; credential errors do not. See Token Lifecycle.

Testing without Firebase

fcm() takes an optional messaging client, which must implement only the sendEach shape the provider uses:

fcm({
  serviceAccount: { project_id: "test" },
  messaging: {
    sendEach: async (messages) => ({
      responses: messages.map(() => ({ success: true })),
    }),
  },
});

That is how better-push's own test suite exercises the provider without touching Firebase or the network.

On this page