Next.js Firebase Push Notifications: Full Setup With FCM & Service Worker

Muhaymin Bin Mehmood

Muhaymin Bin Mehmood

·
8 min read
Published Mar 9, 2024
Updated Oct 9, 2026
Implementing Firebase Push Notifications in Next.js 15: A Step-by-Step Guide banner image
14 min read
1,160 words
18 sections9 code blocks

Push notifications let your Next.js app reach users when they don't have it open. With Firebase Cloud Messaging (FCM) you need four pieces: the Firebase SDK in the browser to ask permission and register, a service worker to receive messages in the background, an API route that stores each browser's registration, and a server that sends messages with the Firebase Admin SDK.

This guide builds all four for the Next.js App Router, using the current APIs: Firebase JS SDK 13's register() and onRegistered(), which replace the deprecated getToken(), and firebase-admin 14.

How this was tested (October 2026): the client component was built against firebase 13.0.0 and its permission, registration and foreground-message logic run in Chrome 152 with the Firebase SDK stubbed; the service worker's logic was run with stubbed browser globals; the send call was type-checked against firebase-admin 14.5.0; the API route was run with real requests. We did not deliver a real notification, which needs your own Firebase project — use the Troubleshooting section if yours doesn't arrive.

How FCM Push Works in a Next.js App

PieceWhere it runsWhat it does
PushNotifications componentBrowser (client component)Asks permission on a click, registers with FCM, shows messages while the page is open
firebase-messaging-sw.jsService workerReceives messages when the page is in the background or closed
/api/push/registerYour Next.js serverStores each browser's Firebase Installation ID (FID)
sendPush()Your server (Node.js)Sends a message to an FID with firebase-admin

Where a message ends up depends on the page, as the Firebase SDK source shows: if a page of your site is visible, the message goes to onMessage in the page and no system notification appears. Otherwise the service worker gets it, and a message with a notification field is displayed by the SDK automatically.

What Changed: Installation IDs Replace Tokens

  • Firebase JS SDK 13 deprecates getToken() and deleteToken(). Call register() and listen with onRegistered(), which gives you a Firebase Installation ID (FID) to store on your server. onUnregistered() tells you when it's no longer valid.
  • firebase-admin 14 sends to { fid } (the token target is deprecated) and requires Node.js 22 or newer.
  • No more Server Key. The legacy FCM APIs that used it have been shut down. Servers authenticate with a service account through the Admin SDK.

Existing apps that use getToken() keep working for now, but new code should use the FID flow below.

1. Set Up Your Firebase Project

  1. Create a project in the Firebase console (or open an existing one).
  2. Add a Web app (the </> icon) and copy the config object it shows.
  3. Go to Project settings → Cloud Messaging → Web Push certificates and click Generate key pair. The public key is your VAPID key.
  4. For the server, go to Project settings → Service accounts and generate a private key (a JSON file). Keep it secret; never commit it.

Firebase's own web client setup guide covers the console steps in more detail.

2. Install Firebase and Add Your Config

Bash
npm install firebase
npm install firebase-admin   # for sending, on the server
Bash
# .env.local
NEXT_PUBLIC_FIREBASE_API_KEY=...
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your-project
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=...
NEXT_PUBLIC_FIREBASE_APP_ID=...
NEXT_PUBLIC_FIREBASE_VAPID_KEY=...   # Web Push certificate key pair

# Server only (from a service account key)
FIREBASE_PROJECT_ID=your-project
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-xxxx@your-project.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"

The NEXT_PUBLIC_ values are safe to expose — they identify your project, they don't grant access. The service account values are secrets and must stay server-side.

Create lib/firebase.js:

JavaScript
import { getApp, getApps, initializeApp } from "firebase/app";

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,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

// Reuse the app on hot reloads instead of initializing it twice
export const firebaseApp = getApps().length ? getApp() : initializeApp(firebaseConfig);

3. Add the Service Worker

Create public/firebase-messaging-sw.js. Files in public/ are served from the site root, which is where the SDK looks for it:

JavaScript
importScripts("https://www.gstatic.com/firebasejs/13.0.0/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/13.0.0/firebase-messaging-compat.js");

// Service workers can't read process.env, so repeat the public config here
firebase.initializeApp({
  apiKey: "YOUR_API_KEY",
  authDomain: "YOUR_PROJECT.firebaseapp.com",
  projectId: "YOUR_PROJECT_ID",
  messagingSenderId: "YOUR_SENDER_ID",
  appId: "YOUR_APP_ID",
});

const messaging = firebase.messaging();

// Messages with a `notification` field are shown by the SDK automatically.
// Only build a notification yourself for data-only messages, or users see two.
messaging.onBackgroundMessage((payload) => {
  if (payload.notification) return;

  const { title = "New update", body = "", link = "/" } = payload.data ?? {};
  return self.registration.showNotification(title, {
    body,
    icon: "/icon-192.png",
    data: { link },
  });
});

// Clicks on the notifications created above (the SDK handles its own)
self.addEventListener("notificationclick", (event) => {
  const link = event.notification.data?.link;
  if (!link) return;
  event.notification.close();
  event.waitUntil(clients.openWindow(link));
});
  • Paste your config in directly. Service workers don't go through the Next.js build, so process.env doesn't exist there. These values are public anyway.
  • Don't show notification messages twice. For a message with a notification field, the SDK displays it and then still calls onBackgroundMessage. Calling showNotification for those too gives users two notifications, which is why the handler returns early.
  • Data-only messages (no notification field) are yours to display, with whatever options you like. The notificationclick listener opens their link; clicks on SDK-displayed notifications are handled by the SDK.
  • Keep the version in the importScripts URLs in line with the firebase version in your package.json.

4. Ask for Permission and Register

Create components/PushNotifications.jsx:

JSX
"use client";

import { useEffect, useState } from "react";
import { getMessaging, isSupported, onMessage, onRegistered, register } from "firebase/messaging";
import { firebaseApp } from "@/lib/firebase";

const VAPID_KEY = process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY;

function saveRegistration(fid) {
  return fetch("/api/push/register", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ fid }),
  });
}

export default function PushNotifications() {
  // checking | unsupported | default | granted | denied | error
  const [status, setStatus] = useState("checking");
  const [lastMessage, setLastMessage] = useState(null);

  useEffect(() => {
    let cancelled = false;
    const unsubscribers = [];

    (async () => {
      if (!(await isSupported())) {
        if (!cancelled) setStatus("unsupported");
        return;
      }
      if (cancelled) return;

      const messaging = getMessaging(firebaseApp);
      unsubscribers.push(
        // Called with the Firebase Installation ID (FID) once registration succeeds
        onRegistered(messaging, (fid) => saveRegistration(fid)),
        // Messages that arrive while this page is visible
        onMessage(messaging, (payload) => setLastMessage(payload.notification ?? payload.data))
      );
      setStatus(Notification.permission);

      // Allowed on an earlier visit: refresh the registration without prompting
      if (Notification.permission === "granted") {
        await register(messaging, { vapidKey: VAPID_KEY });
      }
    })().catch(() => !cancelled && setStatus("error"));

    return () => {
      cancelled = true;
      unsubscribers.forEach((unsubscribe) => unsubscribe());
    };
  }, []);

  async function enable() {
    try {
      // Ask only after a click: browsers block or hide prompts that appear on page load
      const permission = await Notification.requestPermission();
      setStatus(permission);
      if (permission === "granted") {
        await register(getMessaging(firebaseApp), { vapidKey: VAPID_KEY });
      }
    } catch {
      setStatus("error");
    }
  }

  if (status === "checking") return null;
  if (status === "unsupported") {
    return <p>Push notifications aren't supported here. On iPhone and iPad, add this site to your Home Screen first.</p>;
  }
  if (status === "denied") {
    return <p>Notifications are blocked. You can allow them in your browser's site settings.</p>;
  }
  if (status === "default" || status === "error") {
    return (
      <div>
        {status === "error" && <p>Couldn't turn on notifications. Please try again.</p>}
        <button onClick={enable}>Turn on notifications</button>
      </div>
    );
  }
  return (
    <div role="status">
      <p>Notifications are on.</p>
      {lastMessage && (
        <p>
          <strong>{lastMessage.title}</strong> {lastMessage.body}
        </p>
      )}
    </div>
  );
}
  • Permission is requested only from a click. Firefox ignores permission requests that don't come from a user action, and Chrome switches to a quieter, easily missed prompt for sites whose prompts users often dismiss — which unexpected page-load prompts cause.
  • isSupported() is checked first. On iPhone and iPad, web push only works once the user has added your site to the Home Screen (iOS 16.4 and later), so unsupported browsers get a hint instead of an error.
  • Returning visitors who already allowed notifications are re-registered silently, so their registration stays current.
  • Both listeners are unsubscribed when the component unmounts.

Render it once, for example in the root layout — or better, in a settings page where users expect to manage notifications:

JSX
// app/layout.jsx
import PushNotifications from "@/components/PushNotifications";

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {children}
        <PushNotifications />
      </body>
    </html>
  );
}

5. Save Each Registration on Your Server

Create app/api/push/register/route.js. This is where you connect the FID to the signed-in user in your database:

JavaScript
// app/api/push/register/route.js
export async function POST(request) {
  const { fid } = await request.json();
  if (typeof fid !== "string" || fid.length === 0) {
    return Response.json({ error: "fid is required" }, { status: 400 });
  }

  // Save the FID with the signed-in user, e.g. in your database:
  // await db.pushRegistrations.upsert({ userId, fid });

  return Response.json({ ok: true });
}

6. Send a Notification from the Server

Create lib/firebase-admin.js (see Firebase's Admin SDK setup):

JavaScript
import { cert, getApps, initializeApp } from "firebase-admin/app";
import { getMessaging } from "firebase-admin/messaging";

// Server only: these come from a service account key, never NEXT_PUBLIC_
const app =
  getApps()[0] ??
  initializeApp({
    credential: cert({
      projectId: process.env.FIREBASE_PROJECT_ID,
      clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
      privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
    }),
  });

export const adminMessaging = getMessaging(app);

export function sendPush(fid, { title, body, link }) {
  return adminMessaging.send({
    fid,
    notification: { title, body },
    webpush: {
      fcmOptions: { link }, // opened on click; must be a URL on your own site
    },
  });
}

Then send from a route handler, server action, cron job or script — never from the browser:

JavaScript
import { sendPush } from "@/lib/firebase-admin";

try {
  await sendPush(fid, {
    title: "New post published",
    body: "How to Fix JavaScript Heap Out of Memory",
    link: "https://www.example.com/post/heap-out-of-memory",
  });
} catch (err) {
  if (err.code === "messaging/installation-id-not-registered") {
    // The browser unsubscribed or the registration expired: forget this FID
    // await db.pushRegistrations.delete({ fid });
  } else {
    throw err;
  }
}
  • notification gives the title and body the SDK displays when your site isn't in front. webpush.fcmOptions.link is opened on click; the SDK only opens links on your own origin.
  • When a browser unsubscribes or its registration expires, sending fails with messaging/installation-id-not-registered. Delete that FID so you stop sending to it.
  • firebase-admin 14 needs Node.js 22+. In Next.js, keep this code in the Node.js runtime (the default for route handlers), not the Edge runtime.

To test without writing a sender, use the Firebase console's Messaging section and send a test message.

Troubleshooting Firebase Push in Next.js

ProblemCheck
Nothing happens when you click the buttonIs the page on HTTPS or localhost? Is NEXT_PUBLIC_FIREBASE_VAPID_KEY set? Look for errors in the console.
onRegistered never firesIs /firebase-messaging-sw.js reachable at the site root (open it in the browser)? Check DevTools → Application → Service Workers.
Two notifications for one messageYour onBackgroundMessage also calls showNotification for messages that have a notification field.
No notification while the site is openExpected: foreground messages go to onMessage. Show them in your UI.
Notifications never appear on iPhoneThe site must be added to the Home Screen (iOS 16.4+), and permission requested from inside it.
Send fails with installation-id-not-registeredThat browser is no longer registered; delete the FID.
Permission prompt never appearsThe user already blocked it. Only they can re-enable it in site settings.

FAQs

How do I add push notifications to a Next.js app?

Set up a Firebase project with a Web Push key, add public/firebase-messaging-sw.js, ask permission from a client component and call register(), store the FID from onRegistered() on your server, and send with firebase-admin.

Does this work with the App Router?

Yes. The permission and registration code lives in a client component ("use client"), the service worker is a static file in public/, and registrations are stored with a route handler.

Is getToken() deprecated?

In Firebase JS SDK 13, yes: use register() with onRegistered() instead. Existing token-based code still runs for now.

Why do I get two notifications for one message?

Because the SDK already displays messages that have a notification field. Only call showNotification in onBackgroundMessage for data-only messages.

Why don't I see a notification while my site is open?

When a page of your site is visible, FCM delivers the message to onMessage in that page instead of showing a system notification. Display it in your UI.

Do web push notifications work on iPhone?

Yes, on iOS and iPadOS 16.4 and later, but only for sites the user has added to their Home Screen.

Where do I find the Server Key?

You don't need it: the legacy APIs that used it have been shut down. Send with the Firebase Admin SDK and a service account instead.

No comments yet. Be the first to comment!

Leave a Comment

2000 characters remaining

Related Articles

View all
Muhaymin Bin Mehmood

About Muhaymin Bin Mehmood

Front-end Developer skilled in the MERN stack, experienced in web and mobile development. Proficient in React.js, Node.js, and Express.js, with a focus on client interactions, sales support, and high-performance applications.