Firebase Push Notifications
built with: Firebase Cloud Messaging + MongoDB + GitHub Actions cron
Two Firebase SDKs, a service worker that cannot read env vars, a cron job standing in for Vercel Cron, and a MongoDB queue that never repeats a quote early.
Two SDKs, two trust boundaries
`firebase-client.ts` and `firebase-admin.ts` look similar (both call `initializeApp`) but never overlap in what they hold. The client file carries only `NEXT_PUBLIC_*` config, which is public by design, it ends up in the browser bundle regardless. The admin file carries a service-account private key and is imported exclusively from Route Handlers, where it never reaches the client bundle.
Client-side, `registerAndGetFcmToken()` asks the browser for a device token and hands it to `/api/notifications/register`, which upserts it into the `Device` collection by `deviceId` (not `fcmToken`), so re-registering the same device after a permission re-grant or token refresh updates the existing row instead of creating a duplicate.
// src/lib/firebase-admin.ts
function adminApp() {
if (getApps().length) return getApps()[0];
return initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
// Vercel env vars store literal "\n" — real newlines are required by the SDK.
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
})
});
}A service worker that cannot read env vars
FCM needs its own service worker, registered at its own scope (`/firebase-cloud-messaging-push-scope`), separate from the Serwist worker that handles offline caching. Static files served straight out of `public/` never go through Next.js, so `firebase-messaging-sw.js` cannot read `process.env` at all, and its Firebase config is hardcoded as literals instead. That is safe here only because every one of those values is the same `NEXT_PUBLIC_*` config already shipped to the client bundle; nothing secret lives in that file.
The payload itself is sent data-only, never as an FCM `notification` payload. The messaging SDK auto-displays any `notification` field on top of this worker’s own `showNotification()` call, which produced a visible duplicate during testing before the payload shape was locked down to `data` only.
// public/firebase-messaging-sw.js
messaging.onBackgroundMessage((payload) => {
const { title, body } = payload.data || {};
self.registration.showNotification(title || 'QuickRecall', { body });
});Round-robin, not random
The cron route originally picked a quote with a MongoDB `$sample`, which is uniformly random and can hand out the same quote twice before the rest of the pool has ever been sent. `NotificationTemplate` now carries a `lastSentAt`, and the cron always claims the active template with the oldest (or still-unset) value via a single atomic `findOneAndUpdate`, so the full set cycles through once before anything repeats.
Seeding is a separate idempotent script, not a migration: `bulkWrite` upserts by `body` with `$setOnInsert`, so re-running it after adding new quotes only inserts what is new. New quotes naturally get picked first, since their `lastSentAt` is unset and sorts before every quote that has already gone out once.
// src/app/api/cron/send-quote/route.ts
const [template, devices] = await Promise.all([
NotificationTemplate.findOneAndUpdate({ active: true }, { lastSentAt: new Date() }, { sort: { lastSentAt: 1 } }),
Device.find({ enabled: true })
]);A GitHub Actions cron standing in for Vercel Cron
Vercel's Hobby plan caps Cron Jobs at once a day, too coarse for a quote that should land several times across the day. `.github/workflows/notify-cron.yml` runs on GitHub's own schedule instead and just calls the deployed route over HTTPS with a bearer token, which sidesteps the plan limit entirely since the schedule lives outside Vercel.
The route itself stays a plain authenticated HTTP endpoint. Anything capable of firing a scheduled request, GitHub Actions here, could call it, which kept the cron logic decoupled from any one scheduler.
Important
Gotcha
Where it lives in the repo
- src/lib/firebase-client.ts
- src/lib/firebase-admin.ts
- public/firebase-messaging-sw.js
- src/app/api/notifications/register/route.ts
- src/app/api/cron/send-quote/route.ts
- src/models/Device.ts
- src/models/NotificationTemplate.ts
- scripts/seed-notification-templates.mjs
- .github/workflows/notify-cron.yml