See all blogs

April 9, 2026

Building a Progressive Web App with Next.js and Firebase Push Notifications

Building a Progressive Web App with Next.js and Firebase Push NotificationsBuilding a Progressive Web App with Next.js and Firebase Push Notifications

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.

backstory

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:

  1. Make Next.js installable as a PWA.
  2. Add Firebase Cloud Messaging so admin devices receive push notifications.

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.

emotional rollercoaster

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:

  • A valid web app manifest so the browser can install the app.
  • Proper app metadata in app/layout.tsx.
  • App icons that the browser can actually fetch.
  • A Firebase service worker for background push notifications.
  • A browser-side Firebase client to request permission and fetch an FCM token.
  • A server-side notification pipeline using 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:

PackagePurpose
nextApp Router, manifest route, metadata, layouts, API routes
firebaseBrowser-side Firebase SDK and FCM token retrieval
firebase-adminServer-side push notification sending
@prisma/clientSave each device token on the authenticated user
better-authIdentify the logged-in user before storing a token
sonnerShow 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:

  1. Created a Firebase project named planet-health-care-system.
  2. Added a web app with the nickname "Planet Care Web".
  3. Enabled Firebase Cloud Messaging.
  4. Generated a Web Push certificate and copied the VAPID public key.
  5. Downloaded the service account key for server-side messaging.

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), or
  • a local serviceAccountKey.json file for development

These 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.json

That 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:

  1. The icons must point to files the browser can actually fetch.
  2. purpose in Next.js typed manifests must be "any" or "maskable", not "any maskable" as one string.
  3. 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:

  1. Register the Firebase service worker.
  2. Ask the user for notification permission.
  3. Call getToken() with the VAPID key.
  4. Return the FCM token.

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:

  1. User clicks a toggle.
  2. Browser asks for permission.
  3. Firebase returns a token.
  4. The app stores that token locally and on the authenticated user.
  5. The UI immediately confirms success.

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:

  • Foreground (app tab active): onMessage() in lib/firebase.ts fires — you must show something manually.
  • Background (tab minimized or closed): 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:

  • Background tab → system notification through the service worker
  • Foreground tab → toast inside the dashboard

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:

  • a stock update brings a drug below its threshold
  • admins with FCM tokens are queried
  • the notification is saved to their notification history
  • Firebase sends push messages to their devices
  • clicking the notification opens the admin directly to that drug's page

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 dev

Then I tested in this order:

  1. Open https://localhost:3000
  2. Sign in to the dashboard
  3. Click the notifications toggle in the profile dropdown
  4. Confirm that the browser grants permission
  5. Confirm the token is saved in the user.fcmToken field
  6. Trigger a low-stock event or another business action
  7. Verify that admin devices receive the push notification
  8. Click the notification and confirm it opens the correct dashboard route

Two browser tools helped a lot here:

  • Application → Manifest
  • Application → Service Workers

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:

  • Serve the app over HTTPS
  • Add all NEXT_PUBLIC_FIREBASE_* variables in the Vercel dashboard
  • Add FIREBASE_SERVICE_ACCOUNT as a stringified JSON in Vercel's env settings
  • Keep public/firebase-messaging-sw.js accessible at the root
  • Ensure the manifest icons are valid and reachable
  • Add serviceAccountKey.json to .gitignore — never commit it

I also recommend testing:

  • foreground notifications
  • background notifications
  • notification click behavior
  • the unread count badge
  • the in-app notification history page

technical details

how it works

The Planet Health Care implementation has a very clean end-to-end flow:

  1. PWA metadata — Next.js serves manifest.webmanifest from app/manifest.ts.
  2. Installability — app/layout.tsx exposes manifest metadata, theme color, and Apple app metadata.
  3. Service worker — The browser registers /firebase-messaging-sw.js, which also handles offline caching.
  4. Permission — The dashboard asks the user for notification permission.
  5. Token — Firebase returns an FCM token for that browser instance.
  6. Persistence — The app stores the token in localStorage and on the authenticated Prisma User.
  7. Event trigger — A business event such as a low-stock alert calls notifyRoles().
  8. DB record — The notification is saved to the Notification table for all target users.
  9. Server send — firebase-admin sends a web push payload to each saved token.
  10. Foreground UX — Active tabs show a Sonner toast.
  11. Background UX — Inactive tabs show a system notification through the service worker.

complete flow diagram

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.url

final file map

This 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.prisma

setup

prerequisites

Before starting, make sure you have:

  • Node.js installed
  • pnpm installed
  • A Next.js App Router project
  • A Firebase project with Cloud Messaging enabled
  • A generated VAPID public key
  • Firebase Admin credentials for the server
  • A database and Prisma set up
  • Authentication in place so you know which user owns the token
  • HTTPS for local testing, or at least a secure context

step-by-step

1. Install the required packages

pnpm add firebase firebase-admin

2. 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.json

4. 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:

  • manifest
  • applicationName
  • appleWebApp
  • icons.apple
  • themeColor

6. 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 all
  • GET /api/notification/[id] — single
  • PATCH /api/notification/[id] — mark read
  • DELETE /api/notification/[id] — delete
  • PATCH /api/notification/mark-all-read — mark all read
  • GET /api/notification/unread-count — count for the badge

12. 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 dev

using this pattern in a real dashboard app

This exact pattern works especially well when:

  • only authenticated users can subscribe
  • tokens are tied to real users
  • notifications should follow business roles like admin and provider
  • a database event should trigger the push
  • users need an in-app notification history, not just OS popups

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.

conclusion

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:

  • Next.js App Router for manifest and metadata
  • a root Firebase messaging service worker in public/ that also handles offline caching
  • browser-side token generation with firebase/messaging
  • Prisma storage of the fcmToken on the authenticated user
  • a Notification model for in-app history and unread counts
  • Firebase Admin on the server to send push payloads
  • notifyRoles() as a clean server-side utility called from any API route
  • a foreground listener for in-app toast notifications
  • real event-driven triggers from health system events

Key takeaways from this build:

  • manifest.webmanifest and Firebase push are related but separate concerns
  • your manifest icons must point to files the browser can actually fetch
  • service workers cannot read process.env — hardcode Firebase config inside them
  • the browser token should be tied to a real authenticated user
  • storing the token in Prisma makes role-based sending very straightforward
  • saving notifications to the database gives users an in-app history even without push
  • foreground and background notification UX should both be handled
  • a working local HTTPS setup saves a lot of pain while testing
  • no-cache on the service worker file ensures users always get the latest version

What I would do differently next time:

  • support multiple device tokens per user instead of a single fcmToken
  • add token refresh handling and automatic stale token cleanup
  • build a richer notification UI with filters by type and date
  • add a dedicated domain event system so multiple features can trigger notifications without coupling to specific API routes
  • explore deeper PWA offline behavior beyond basic app shell caching

This 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.

additional resources

  • Desishub guide: Next.js PWA with Firebase
  • Next.js metadata docs
  • Firebase Cloud Messaging docs
  • Firebase Admin SDK docs
  • Progressive Web Apps on web.dev
  • Prisma docs
  • PWA icon generator — realfavicongenerator.net

See all blogs