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
| Piece | Where it runs | What it does |
|---|---|---|
| PushNotifications component | Browser (client component) | Asks permission on a click, registers with FCM, shows messages while the page is open |
| firebase-messaging-sw.js | Service worker | Receives messages when the page is in the background or closed |
| /api/push/register | Your Next.js server | Stores 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
- Create a project in the Firebase console (or open an existing one).
- Add a Web app (the </> icon) and copy the config object it shows.
- Go to Project settings → Cloud Messaging → Web Push certificates and click Generate key pair. The public key is your VAPID key.
- 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
npm install firebase
npm install firebase-admin # for sending, on the server# .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:
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:
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:
"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:
// 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:
// 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):
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:
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
| Problem | Check |
|---|---|
| Nothing happens when you click the button | Is the page on HTTPS or localhost? Is NEXT_PUBLIC_FIREBASE_VAPID_KEY set? Look for errors in the console. |
| onRegistered never fires | Is /firebase-messaging-sw.js reachable at the site root (open it in the browser)? Check DevTools → Application → Service Workers. |
| Two notifications for one message | Your onBackgroundMessage also calls showNotification for messages that have a notification field. |
| No notification while the site is open | Expected: foreground messages go to onMessage. Show them in your UI. |
| Notifications never appear on iPhone | The site must be added to the Home Screen (iOS 16.4+), and permission requested from inside it. |
| Send fails with installation-id-not-registered | That browser is no longer registered; delete the FID. |
| Permission prompt never appears | The 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.
Related Blogs:
Related Articles
View allAbout 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.




