April 9, 2026


Reference: jb.desishub.com/blog/nextjs-pwa-with-firebase
This guide follows the exact setup I used in planet-health-care-system, not a generic sample project.
If you are only interested in the implementation, you can skip to the technical part
I wanted Planet Health Care System to behave like a serious medical platform, not just a website. The app already handled patients, providers, drugs, pharmacy operations, and dashboard roles. But once real users started depending on it, I needed one more thing: instant alerts.
If a drug fell below minimum stock, I wanted the admin to know immediately. If a sale was refunded, I wanted a notification waiting for them. And if the tab was open, I wanted a foreground toast — if it was in the background, a real push notification. While I was at it, I wanted the application to be installable like a proper Progressive Web App.
That meant solving two related but different problems:
The Desishub guide gave me the right direction, but I still needed to adapt it to my real app architecture: Next.js App Router, Better Auth, Prisma, PostgreSQL, role-based users, a custom dashboard layout, and a production flow where notifications are triggered by real health system events.
That is what this article documents.
rabbit hole: part 1 - defining what "PWA" actually meant in my app
Day 1: Separating installability from notifications
At first, I treated "PWA" and "push notifications" like one feature. They are related, but they are not the same thing.
For Planet Health Care, I needed both:
app/layout.tsx.firebase-admin.One of the most important lessons here is this:
Your Firebase messaging service worker is not the same thing as your PWA manifest setup.
That distinction matters because you can have push notifications working while installability is broken, or installability working while push notifications are broken.
rabbit hole: part 2 - choosing the exact stack
Day 1: Picking tools that fit the app
Planet Health Care already used a fairly modern stack, so I did not want to bolt on anything unnecessary. The PWA and push notification work stayed inside the tools I was already using:
{
"next": "15.x",
"react": "19.x",
"firebase": "^10.7.1",
"firebase-admin": "latest",
"@prisma/client": "latest",
"better-auth": "latest",
"sonner": "latest"
}Here is how each one fit into the build:
| Package | Purpose |
|---|---|
next | App Router, manifest route, metadata, layouts, API routes |
firebase | Browser-side Firebase SDK and FCM token retrieval |
firebase-admin | Server-side push notification sending |
@prisma/client | Save each device token on the authenticated user |
better-auth | Identify the logged-in user before storing a token |
sonner | Show foreground notifications as dashboard toasts |
I also enabled HTTPS in development because service workers and push notifications are much happier in a secure context:
{
"scripts": {
"dev": "next dev --experimental-https"
}
}rabbit hole: part 3 - setting up Firebase for the web app
Day 2: Firebase console work
Before touching code, I set up Firebase properly:
planet-health-care-system.In Planet Health Care, the browser uses public Firebase values through NEXT_PUBLIC_* environment variables, while the server uses either:
FIREBASE_SERVICE_ACCOUNT as a stringified JSON (production), orserviceAccountKey.json file for developmentThese are the exact browser-side variable names I used:
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
NEXT_PUBLIC_VAPID_KEY=And the server-side option my app uses in production:
FIREBASE_SERVICE_ACCOUNT=For local development, I support:
serviceAccountKey.jsonThat flexibility made the setup much easier across local and deployed environments.
rabbit hole: part 4 - making the app installable
Day 2: Building the PWA metadata properly
The first real PWA file I added was the manifest route in app/manifest.ts.
import { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Planet Health Care System",
short_name: "PlanetCare",
description:
"A digital health care platform for patients, providers, and pharmacy operations.",
id: "/",
start_url: "/",
scope: "/",
display: "standalone",
orientation: "portrait",
background_color: "#ffffff",
theme_color: "#0092b8",
icons: [
{
src: "/favicons/favicon.ico",
sizes: "any",
type: "image/x-icon",
},
{
src: "/favicons/android/launchericon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/favicons/android/launchericon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{
src: "/favicons/ios/180.png",
sizes: "180x180",
type: "image/png",
purpose: "any",
},
{
src: "/favicons/windows/Square150x150Logo.scale-100.png",
sizes: "150x150",
type: "image/png",
purpose: "any",
},
],
};
}There are three practical details here that matter a lot:
purpose in Next.js typed manifests must be "any" or "maskable", not "any maskable" as one string.id, scope, start_url, theme_color, and display: "standalone" help browsers treat the app as installable.Then I wired the top-level metadata in app/layout.tsx:
import type { Metadata, Viewport } from "next";
export const metadata: Metadata = {
title: "PlanetCare",
description:
"A digital health care platform for patients, providers, and pharmacy operations.",
applicationName: "Planet Health Care System",
manifest: "/manifest.webmanifest",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "PlanetCare",
},
icons: {
apple: "/favicons/ios/180.png",
},
};
export const viewport: Viewport = {
themeColor: "#0092b8",
};This is what makes the app feel like a proper installable experience instead of just exposing a manifest file and hoping the browser figures everything out.
rabbit hole: part 5 - the special service worker header
Day 3: Making the Firebase worker load cleanly
Next, I configured next.config.ts so the Firebase service worker is served with the right headers, alongside security headers for the whole app:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
],
},
{
source: "/firebase-messaging-sw.js",
headers: [
{
key: "Content-Type",
value: "application/javascript; charset=utf-8",
},
{
key: "Cache-Control",
value: "no-cache, no-store, must-revalidate",
},
{
key: "Service-Worker-Allowed",
value: "/",
},
],
},
];
},
};
export default nextConfig;Service-Worker-Allowed: / is especially important because it allows the worker to control the app from the site root. The Cache-Control: no-cache on the service worker file also matters — if that file gets cached, users will never receive notification bug fixes after a deploy.
rabbit hole: part 6 - initializing Firebase on the client
Day 3: Browser-side messaging
The browser-side setup lives in lib/firebase.ts.
"use client";
import { getApp, getApps, initializeApp, type FirebaseApp } from "firebase/app";
import {
deleteToken,
getMessaging,
getToken,
isSupported,
onMessage,
type MessagePayload,
type Messaging,
} from "firebase/messaging";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};
let app: FirebaseApp | null = null;
let messagingPromise: Promise<Messaging | null> | null = null;
function getFirebaseApp(): FirebaseApp | null {
if (typeof window === "undefined") return null;
if (app) return app;
app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApp();
return app;
}
async function getFirebaseMessaging(): Promise<Messaging | null> {
if (typeof window === "undefined") return null;
if (!messagingPromise) {
messagingPromise = isSupported()
.then((supported) => {
const firebaseApp = getFirebaseApp();
if (!supported || !firebaseApp) return null;
return getMessaging(firebaseApp);
})
.catch(() => null);
}
return messagingPromise;
}The key pattern here is if (typeof window === "undefined"). Since Next.js runs on the server too, I only initialize browser messaging in the client environment. I also use isSupported() to gracefully handle browsers that do not support FCM — such as Firefox in private mode.
The function that actually powers subscription is this one:
export const requestNotificationPermission = async (): Promise<string | null> => {
try {
if (typeof window === "undefined" || !("Notification" in window)) return null;
const messaging = await getFirebaseMessaging();
if (!messaging) return null;
let serviceWorkerRegistration: ServiceWorkerRegistration | undefined;
if ("serviceWorker" in navigator) {
serviceWorkerRegistration = await navigator.serviceWorker.register(
"/firebase-messaging-sw.js"
);
}
const permission = await Notification.requestPermission();
if (permission !== "granted") return null;
const token = await getToken(messaging, {
vapidKey: process.env.NEXT_PUBLIC_VAPID_KEY,
serviceWorkerRegistration,
});
return token || null;
} catch (error) {
console.error("Error getting FCM token:", error);
return null;
}
};This function does the entire client-side handshake:
getToken() with the VAPID key.That token is the address of the current browser installation.
rabbit hole: part 7 - building the background worker
Day 4: The file that runs when the app is not in focus
The service worker lives in public/firebase-messaging-sw.js. Service workers must be served from the root of your site, and Next.js serves everything in public/ at the root — so that is where it goes. Importantly, service workers have no access to process.env, which is why the Firebase config is hardcoded inside this file.
importScripts(
"https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js"
);
importScripts(
"https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js"
);
// PWA caching
const APP_SHELL_CACHE = "planetcare-app-shell-v1";
const APP_SHELL_ASSETS = [
"/",
"/manifest.webmanifest",
"/favicons/android/launchericon-192x192.png",
"/favicons/android/launchericon-512x512.png",
"/favicons/ios/180.png",
"/favicons/favicon.ico",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(APP_SHELL_CACHE).then((cache) =>
cache.addAll(
APP_SHELL_ASSETS.map((asset) => new Request(asset, { cache: "reload" }))
)
)
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((keys) =>
Promise.all(
keys.filter((k) => k !== APP_SHELL_CACHE).map((k) => caches.delete(k))
)
)
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) return;
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() => caches.match("/"))
);
return;
}
event.respondWith(
caches.match(event.request).then(
(cached) => cached || fetch(event.request)
)
);
});
// Firebase — hardcoded because process.env is unavailable in service workers
firebase.initializeApp({
apiKey: "YOUR_FIREBASE_API_KEY",
authDomain: "YOUR_FIREBASE_AUTH_DOMAIN",
projectId: "YOUR_FIREBASE_PROJECT_ID",
storageBucket: "YOUR_FIREBASE_STORAGE_BUCKET",
messagingSenderId: "YOUR_FIREBASE_MESSAGING_SENDER_ID",
appId: "YOUR_FIREBASE_APP_ID",
});
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
const notificationTitle =
payload.notification?.title || payload.data?.title || "New Notification";
const notificationOptions = {
body:
payload.notification?.body ||
payload.data?.body ||
"You have a new notification",
icon: "/favicons/android/launchericon-192x192.png",
requireInteraction: true,
data: {
url: payload.data?.url || "/dashboard/notifications",
...payload.data,
},
};
return self.registration.showNotification(notificationTitle, notificationOptions);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url || "/dashboard/notifications";
event.waitUntil(
clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((windowClients) => {
for (const client of windowClients) {
if ("focus" in client) {
client.navigate(urlToOpen);
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});This file handles the background case. If the user is not actively using the tab, the service worker receives the message and shows a real system notification. When the user clicks it, the app opens or focuses and navigates to the route in payload.data.url. The same worker also handles PWA caching so the app loads even without internet.
rabbit hole: part 8 - saving the browser token in Prisma
Day 4: Giving each user a place to store a device token
Because Planet Health Care already uses Prisma and Better Auth, the cleanest design was to save one token directly on the user model, alongside a Notification table for in-app history.
In prisma/schema.prisma, I added:
model User {
id String @id @default(cuid())
email String @unique
// ... other fields
fcmToken String?
notifications Notification[]
}
enum NotificationType {
LOW_STOCK
EXPIRY_WARNING
SALE_REFUNDED
}
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
body String
type NotificationType
read Boolean @default(false)
data Json?
createdAt DateTime @default(now())
@@index([userId])
@@index([read])
@@map("notification")
}Then I created a subscribe route in app/api/subscribe-fcm/route.ts:
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import db from "@/prisma/instance";
export async function POST(request: Request) {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { token } = await request.json();
if (!token) {
return NextResponse.json({ error: "Token is required" }, { status: 400 });
}
await db.user.update({
where: { id: session.user.id },
data: { fcmToken: token },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Subscribe error:", error);
return NextResponse.json({ error: "Failed to save token" }, { status: 500 });
}
}
export async function DELETE() {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
await db.user.update({
where: { id: session.user.id },
data: { fcmToken: null },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Unsubscribe error:", error);
return NextResponse.json({ error: "Failed to remove token" }, { status: 500 });
}
}This is where Better Auth really helped. Since the API route already knows who the logged-in user is, I can safely attach or remove the FCM token on that user's row.
rabbit hole: part 9 - giving the user a real "Enable notifications" UI
Day 5: Connecting the browser token flow to the dashboard
The subscription flow is triggered from the user profile dropdown in the dashboard. This is the important part:
async function handleToggleNotifications() {
setNotifLoading(true);
try {
if (isSubscribed) {
await unsubscribeFromNotifications();
localStorage.removeItem("fcmToken");
await fetch("/api/subscribe-fcm", { method: "DELETE" });
toast.success("Push notifications disabled for this device.");
} else {
const token = await requestNotificationPermission();
if (!token) {
toast.error("Notification permission was not granted.");
return;
}
localStorage.setItem("fcmToken", token);
const response = await fetch("/api/subscribe-fcm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (!response.ok) throw new Error("Failed to save notification token.");
toast.success("Push notifications enabled.", {
description: "This device can now receive dashboard alerts.",
});
}
} catch {
toast.error("Could not change notification settings.");
} finally {
setNotifLoading(false);
}
}I like this pattern because it keeps the flow explicit:
That makes debugging much easier than trying to auto-subscribe silently.
rabbit hole: part 10 - handling foreground messages
Day 5: The app is open, but I still want a good UX
Background notifications are only half the story. If a provider is already inside the dashboard, I do not want the app to feel dead. The key distinction is this:
onMessage() in lib/firebase.ts fires — you must show something manually.onBackgroundMessage() in the service worker fires automatically.I handled foreground in the dashboard layout:
useEffect(() => {
onMessageListener().then((payload) => {
if (!payload) return;
toast(payload.notification?.title ?? "New notification", {
description: payload.notification?.body,
action: {
label: "Open",
onClick: () => {
window.location.href =
payload.data?.url ?? "/dashboard/notifications";
},
},
});
if (Notification.permission === "granted") {
new Notification(payload.notification?.title ?? "New notification", {
body: payload.notification?.body,
icon: "/favicons/android/launchericon-192x192.png",
data: { url: payload.data?.url ?? "/dashboard/notifications" },
});
}
});
}, []);This is one of my favorite parts of the implementation because it makes the product feel alive:
Same message pipeline, better user experience.
rabbit hole: part 11 - sending notifications from the server
Day 6: The server-side part that ties it all together
The browser can receive notifications now, but the server still needs a way to send them.
For that, I built lib/firebaseAdmin.ts:
import admin from "firebase-admin";
import { createRequire } from "module";
const requireFromRoot = createRequire(import.meta.url);
function getServiceAccount(): admin.ServiceAccount {
if (process.env.FIREBASE_SERVICE_ACCOUNT) {
const parsed = JSON.parse(
process.env.FIREBASE_SERVICE_ACCOUNT
) as admin.ServiceAccount;
return {
...parsed,
privateKey: parsed.privateKey?.replace(/\\n/g, "\n"),
};
}
const local = requireFromRoot("../serviceAccountKey.json") as admin.ServiceAccount;
return {
...local,
privateKey: local.privateKey?.replace(/\\n/g, "\n"),
};
}
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(getServiceAccount()),
});
}
export const messaging = admin.messaging();
export default admin;Then I created lib/notify.ts, which finds all users with a given role and a saved token, persists the notification to the database, and sends push messages:
import { NotificationType, Role } from "@/app/generated/prisma/enums";
import { messaging } from "@/lib/firebaseAdmin";
import db from "@/prisma/instance";
type NotifyPayload = {
title: string;
body: string;
type: NotificationType;
targetRoles: Role[];
data?: Record<string, string>;
};
export async function notifyRoles(payload: NotifyPayload): Promise<void> {
const users = await db.user.findMany({
where: {
role: { in: payload.targetRoles },
banned: false,
},
select: { id: true, fcmToken: true },
});
if (users.length === 0) return;
await db.notification.createMany({
data: users.map((user) => ({
userId: user.id,
title: payload.title,
body: payload.body,
type: payload.type,
data: payload.data ?? undefined,
read: false,
})),
});
const pushRecipients = users.filter((user) => Boolean(user.fcmToken));
await Promise.allSettled(
pushRecipients.map(async (user) => {
try {
await messaging.send({
notification: { title: payload.title, body: payload.body },
data: {
url: payload.data?.url ?? "/dashboard/notifications",
type: payload.type,
...(payload.data ?? {}),
},
token: user.fcmToken as string,
});
} catch (error) {
console.error(`Failed to send FCM to user ${user.id}:`, error);
}
})
);
}Users who have not enabled push notifications are skipped gracefully — they will still see the notification in-app the next time they open the dashboard, because it was saved to the database regardless.
rabbit hole: part 12 - hooking notifications to a real business event
Day 6: Triggering push notifications when health system events occur
This is where the setup stopped being a tutorial and became part of the product.
In Planet Health Care, I send an admin notification when stock falls below the minimum threshold. That logic lives inside the relevant API route:
import { notifyRoles } from "@/lib/notify";
import { NotificationType, Role } from "@/app/generated/prisma/enums";
await notifyRoles({
title: "⚠️ Low Stock Alert",
body: `${drug.genericName} is below minimum stock level (${drug.stock} units remaining).`,
type: NotificationType.LOW_STOCK,
targetRoles: [Role.ADMIN, Role.SUPA_ADMIN],
data: {
drugId: drug.id,
drugName: drug.genericName,
url: `/dashboard/drugs/${drug.id}`,
},
});That means the workflow is not artificial. It is real:
That is the exact behavior I wanted from day one.
moment of truth
Day 7: Testing the whole flow end to end
The final checklist I used looked like this:
pnpm devThen I tested in this order:
https://localhost:3000user.fcmToken fieldTwo browser tools helped a lot here:
If manifest.webmanifest loaded correctly and /firebase-messaging-sw.js registered correctly, I knew the wiring was close.
The biggest practical gotcha I hit was icon paths in the manifest. If the browser cannot fetch your icons, installability looks broken even if everything else is fine.
shipping to production
Day 7: Making sure the same setup survives deployment
For production on Vercel, the main rules are simple:
NEXT_PUBLIC_FIREBASE_* variables in the Vercel dashboardFIREBASE_SERVICE_ACCOUNT as a stringified JSON in Vercel's env settingspublic/firebase-messaging-sw.js accessible at the rootserviceAccountKey.json to .gitignore — never commit itI also recommend testing:
The Planet Health Care implementation has a very clean end-to-end flow:
manifest.webmanifest from app/manifest.ts.app/layout.tsx exposes manifest metadata, theme color, and Apple app metadata./firebase-messaging-sw.js, which also handles offline caching.localStorage and on the authenticated Prisma User.notifyRoles().Notification table for all target users.firebase-admin sends a web push payload to each saved token.User opens Planet Health Care dashboard
|
v
Clicks notification toggle in profile dropdown
|
v
Browser registers /firebase-messaging-sw.js
|
v
Notification.requestPermission()
|
+--> denied ---------> show UI error and stop
|
v
Firebase getToken(..., { vapidKey, serviceWorkerRegistration })
|
v
Token saved to localStorage + POST /api/subscribe-fcm
|
v
Prisma updates user.fcmToken
|
v
Business event (e.g. low stock detected)
|
v
notifyRoles({ title, body, type, targetRoles, data })
|
v
Notification saved to DB for all target users
|
v
firebase-admin sends webpush payloads to users with fcmToken
|
+--> active tab ------> Sonner toast in dashboard
|
+--> background tab --> service worker showNotification(...)
|
v
User clicks notification
|
v
Browser focuses or opens app and navigates to payload.data.urlThis is the file structure that mattered most in my app:
app/
layout.tsx
manifest.ts
api/
subscribe-fcm/route.ts
notification/
route.ts
[id]/route.ts
mark-all-read/route.ts
unread-count/route.ts
actions/
notification.ts
services/
notification.ts
hooks/
use-notifications.ts
lib/
firebase.ts
firebaseAdmin.ts
notify.ts
types/
notification.ts
public/
firebase-messaging-sw.js
favicons/
android/launchericon-192x192.png
android/launchericon-512x512.png
ios/180.png
favicon.ico
prisma/
schema.prismaBefore starting, make sure you have:
pnpm installed1. Install the required packages
pnpm add firebase firebase-admin2. Add your Firebase browser environment variables
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
NEXT_PUBLIC_VAPID_KEY=3. Add your Firebase Admin credentials
For production:
FIREBASE_SERVICE_ACCOUNT=For local development, place:
serviceAccountKey.json4. Create the PWA manifest in app/manifest.ts
Use the same structure shown earlier with real, reachable icon paths.
5. Add app metadata in app/layout.tsx
Make sure you set:
manifestapplicationNameappleWebAppicons.applethemeColor6. Add the Firebase worker in public/firebase-messaging-sw.js
This handles background notifications and PWA caching.
7. Add lib/firebase.ts
Initialize Firebase in the browser, request permission, register the worker, and fetch an FCM token.
8. Add a Prisma Notification model and fcmToken field
fcmToken String?
notifications Notification[]Run your migration after updating the schema.
9. Create /api/subscribe-fcm with POST and DELETE handlers
Save or remove the token on the currently authenticated user.
10. Add lib/firebaseAdmin.ts and lib/notify.ts
This is your server-side notification pipeline.
11. Add notification API routes
GET /api/notification — list allGET /api/notification/[id] — singlePATCH /api/notification/[id] — mark readDELETE /api/notification/[id] — deletePATCH /api/notification/mark-all-read — mark all readGET /api/notification/unread-count — count for the badge12. Add server actions, a service layer, and hooks
Use actions/notification.ts → services/notification.ts → hooks/use-notifications.ts to keep your UI components clean.
13. Add a dashboard UI trigger
Call requestNotificationPermission() from a client component, save the token, and POST it to the subscribe route.
14. Mount a foreground listener
Handle onMessageListener() inside your authenticated dashboard layout to show toasts while the app is open.
15. Trigger notifications from a real business event
Call notifyRoles(...) from an API route when a meaningful event occurs.
16. Test on HTTPS
pnpm devThis exact pattern works especially well when:
That is why it fit Planet Health Care so well. I was not building a toy demo. I needed notifications to belong to actual people in the system and to leave a persistent record they could act on later.
Building a Progressive Web App with Next.js and Firebase Push Notifications became much easier once I stopped thinking about it as one magical feature and started treating it like a pipeline.
For Planet Health Care, the winning formula was:
public/ that also handles offline cachingfirebase/messagingfcmToken on the authenticated userNotification model for in-app history and unread countsnotifyRoles() as a clean server-side utility called from any API routeKey takeaways from this build:
manifest.webmanifest and Firebase push are related but separate concernsprocess.env — hardcode Firebase config inside themno-cache on the service worker file ensures users always get the latest versionWhat I would do differently next time:
fcmTokenThis setup works well in Planet Health Care because it stays close to the actual business domain. Stock falls below a threshold, the admin gets notified, and the notification opens the right dashboard page. That is the kind of integration that makes a PWA feel useful instead of decorative.
If you are building an internal dashboard, admin panel, clinic management system, or pharmacy platform with Next.js, this architecture is a very solid starting point.