Push notifications are those little messages that pop up when you get a new chat, your order is delivered, or an app has something important to tell you. They are one of the most effective ways to keep users engaged, and the easiest way to add them to an app is Firebase Cloud Messaging (FCM). This guide pulls together four hands-on walkthroughs into one: native iOS with Swift, Android with Ionic and Capacitor, a React web app, and a full Next.js 14 setup with service workers and a secure server. Pick your platform, or read it all to understand how everything fits together.
What is Firebase Cloud Messaging?
Firebase Cloud Messaging (FCM) is a free Google service that sends push notifications to Android, iOS and the web from one place. It works with the browsers you know, such as Chrome and Safari, and it is built to scale and stay cost effective. It also connects to Google Analytics, so you can target a specific group of users and send personalized messages instead of blasting everyone.
You can send notifications from two places:
- The Firebase console: a friendly online portal, great for testing and campaigns.
- A trusted environment: your own server code, for example Firebase Cloud Functions, a Next.js route handler or any backend. This is how real apps send notifications when something happens, like a new chat message.
Either way, the message goes to the FCM backend, which delivers it to Android, iOS or the web.
Notification messages vs data messages
FCM supports two message types:
- Notification messages: you provide a title, body and image, and FCM handles the display. On native platforms it appears in the system’s standard format.
- Data messages: FCM delivers the data to your app, and your own code decides what to do with it, such as showing a custom toast or opening a specific screen.
You can send to a single device, to multiple devices, or to everyone subscribed to a topic.
Step 1: Create your Firebase project
Every platform below starts the same way:
- Go to Firebase and open the console.
- Click Add project, give it a name and accept the terms. Enabling Google Analytics is optional but useful for targeting.
- Click Add app and choose your platform: iOS (enter your bundle identifier), Android (enter your package name) or Web (enter an app name; Firebase Hosting isn’t needed).
- Download the config file for your platform (
GoogleService-Info.plistfor iOS,google-services.jsonfor Android) or copy the config snippet for web.
iOS: native push notifications with Swift
Add the capability and the Firebase SDK
- In Xcode, select your project, open Signing & Capabilities, click + Capability and add Push Notifications.
- Go to File > Add Package Dependencies, paste the Firebase iOS SDK repository URL (
https://github.com/firebase/firebase-ios-sdk), pick your target and add the package. - Drag the
GoogleService-Info.plistyou downloaded into the project.
If the app crashes at launch right after calling FirebaseApp.configure(), you are almost certainly missing GoogleService-Info.plist. Add it to the project and the crash disappears.
Configure the AppDelegate
import UIKit
import FirebaseCore
import FirebaseMessaging
import UserNotifications
class AppDelegate: UIResponder, UIApplicationDelegate,
MessagingDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure()
Messaging.messaging().delegate = self
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted {
self.registerForRemoteNotifications()
}
}
return true
}
func registerForRemoteNotifications() {
UNUserNotificationCenter.current().getNotificationSettings { settings in
switch settings.authorizationStatus {
case .authorized, .provisional:
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
default:
break
}
}
}
// Hand the APNs device token to Firebase
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}
}
Upload your APNs key
Apple delivers iOS notifications through APNs, so Firebase needs a key from your Apple Developer account:
- In your Apple Developer account, open Keys, click +, give the key a name and enable Apple Push Notifications service (APNs). Then configure, save, continue and register.
- Download the
.p8file. You can only download it once, so keep it safe. - In the Firebase console, open Project settings > Cloud Messaging and upload the
.p8file together with your Key ID and Team ID.
Run the app on a real device and allow notifications. Then, in Firebase, go to Messaging > Create campaign > Firebase Notification messages, enter a title and text, choose your app and publish. The notification arrives on your phone, and tapping it opens the app.
Android: Ionic and Capacitor
If you build with Ionic Angular and Capacitor, the Capacitor Push Notifications plugin does the heavy lifting.
- Create an Ionic Angular app and install the plugin:
npm install @capacitor/push-notifications npx cap sync - In
capacitor.config.ts, set a uniqueappId(for examplecom.example.pushdemo), your app name, and the plugin options:plugins: { PushNotifications: { presentationOptions: ['badge', 'sound', 'alert'] } } - In Firebase, register an Android app. The package name must exactly match your Capacitor
appId. Downloadgoogle-services.jsonand place it inandroid/app.
Add the code
On your home page, add one button to register for push and another to show delivered notifications. Then add the logic:
import { PushNotifications } from '@capacitor/push-notifications';
async registerPush() {
let status = await PushNotifications.checkPermissions();
if (status.receive === 'prompt') {
status = await PushNotifications.requestPermissions();
}
if (status.receive !== 'granted') {
throw new Error('Notification permission denied');
}
await PushNotifications.register();
}
addListeners() {
PushNotifications.addListener('registration', token => {
console.log('Device token:', token.value);
});
PushNotifications.addListener('registrationError', err => {
console.error('Registration error:', err);
});
PushNotifications.addListener('pushNotificationReceived', notification => {
console.log('Received:', notification);
});
PushNotifications.addListener('pushNotificationActionPerformed', action => {
console.log('Action:', action);
});
}
The four listeners cover registration success, registration errors, a notification arriving, and the user tapping a notification. The flow is: check permissions, request them if the status is “prompt”, and only call register() once the status is “granted”. Deploy to a device and test by sending a message from Firebase.
Web: React and Firebase
Set up Firebase in React
Create your React app, install Firebase, and add a file that initializes it:
npm install firebase
import { initializeApp } from "firebase/app";
import { getMessaging, getToken } from "firebase/messaging";
const firebaseConfig = {
// paste the config from the Firebase console
};
const app = initializeApp(firebaseConfig);
export const messaging = getMessaging(app);
export const generateToken = async () => {
const permission = await Notification.requestPermission();
console.log(permission); // "granted" or "denied"
if (permission === "granted") {
const token = await getToken(messaging, {
vapidKey: "YOUR_VAPID_KEY",
});
console.log("Token:", token);
return token;
}
};
Create the VAPID key
In the Firebase console, go to Project settings > Cloud Messaging, find Web Push certificates and click Generate key pair. This is your VAPID key (Voluntary Application Server Identification). It is a public key used to authenticate the push subscription, and it helps generate the unique token for each browser or device.
Call generateToken() inside a useEffect in App.js so the browser asks for permission when the app loads. If the user allows it, you get a token. If they block it, the permission shows as denied and no token is generated.
Add the service worker file
At first you will see a console error saying the default service worker can’t be found. This is expected: create a file named firebase-messaging-sw.js in your public folder (it can be empty for now). As soon as the file exists, the token is generated.
Service workers and the background state
A web app can be in two states when a message arrives:
- Foreground: the tab is open and visible. Your app code handles the message with
onMessage(). - Background: the tab is hidden, behind other tabs, or closed. The service worker handles the message.
A service worker is a script the browser runs in the background, separate from your web page. That is why it keeps working when the page isn’t open, and why it lives in the public folder. It goes through three states: it is registered and installed, then activated (taking control and caching what it needs), and then it listens for events such as push messages and notification clicks.
The service worker code
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/10.12.0/firebase-messaging-compat.js");
firebase.initializeApp({
// the same config you used in firebase.js
});
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
console.log("Background message:", payload);
const { title, body, image } = payload.notification;
self.registration.showNotification(title, {
body,
icon: image,
});
});
Match the version. The version number in the importScripts URLs should match the version of the firebase package you installed. Service workers run natively in the browser, so this file is plain JavaScript, not TypeScript.
Handle foreground messages
In your React component, listen with onMessage and clean up when the component unmounts:
import { onMessage } from "firebase/messaging";
import { messaging, generateToken } from "./notifications/firebase";
import toast, { Toaster } from "react-hot-toast";
useEffect(() => {
generateToken();
const unsubscribe = onMessage(messaging, (payload) => {
console.log("Foreground message:", payload);
toast(payload.notification.body);
});
return () => unsubscribe();
}, []);
// in your JSX:
// <Toaster />
The react-hot-toast package (npm install react-hot-toast) renders a neat toast inside your app, so the user sees something nice even while they are on the page. You can customize it with a close button or other components.
Next.js 14: sending from a trusted server
For real apps, you send notifications from your own backend. Here is how that works with the Firebase Admin SDK in a Next.js 14 route handler.
What you need
- Firebase config in your client file and in the service worker.
- A service account key. In Project settings > Service accounts, click Generate new private key. Save the file as
service-key.jsonin your project, and never commit it to a public repository. - A VAPID key stored in an environment variable (for example in
.env.local). - HTTPS in development. Service workers require a secure connection, so run the dev server with the experimental HTTPS flag and open
https://localhost:3000:next dev --experimental-https
The route handler
import admin from "firebase-admin";
import { Message } from "firebase-admin/messaging";
import { NextRequest, NextResponse } from "next/server";
if (!admin.apps.length) {
const serviceAccount = require("../../../service-key.json"); // adjust the path
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
});
}
export async function POST(request: NextRequest) {
const { token, title, message, link } = await request.json();
const payload: Message = {
token,
notification: { title, body: message },
webpush: link ? { fcmOptions: { link } } : undefined,
};
try {
await admin.messaging().send(payload);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ success: false, error }, { status: 500 });
}
}
The if (!admin.apps.length) check prevents initializing the Admin SDK twice, which can happen in server-rendered environments. The single call admin.messaging().send(payload) is what makes the notification appear on the user’s device. The fcmOptions.link is the URL to open when the user clicks.
Handling clicks in the service worker
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url;
if (!url) return;
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => {
for (const client of list) {
if (client.url === url && "focus" in client) return client.focus();
}
return clients.openWindow(url);
})
);
});
If the tab is already open at that URL, it is focused. Otherwise a new window opens. Use full absolute URLs: a relative path like /contact will never match the full URL, so a new window opens on every click.
Debugging a service worker
- Open DevTools, go to Application > Service Workers to see what is installed and to view its logs.
- Click Unregister and refresh to simulate a brand new visitor.
- Remember: a token is per user, per device and per browser, and it can change during development, so always copy the latest one.
- A known quirk is that the first token request can fail before the service worker finishes installing. A simple retry (up to three attempts) fixes it.
Testing and sending campaigns
Send a test message
- In Firebase, go to Messaging > Create your first campaign and choose Firebase Notification messages. (In-app messages are supported on Android and iOS only, not the web.)
- Enter a title and text. You can also add an image (uploading one may require upgrading to the Blaze plan, because it uses Firebase storage).
- Click Send test message, paste your FCM registration token and click Test.
- The notification should appear, even if the tab is closed. Clicking it activates the tab.
If nothing appears on your computer
On Windows, open Settings > System > Notifications, make sure notifications are on, find Google Chrome under notifications from apps, turn it on and enable the notification banner and control center options. Also check that the browser permission is not blocked. You can reset it from the lock icon in the address bar.
Publish a real campaign
After testing, choose your target app or audience, schedule it (now or later), optionally add analytics labels and custom data as key-value pairs (for example a link key), then publish. The campaign dashboard shows sends, impressions and clicks.
Saving device tokens
In a real product, save each user’s token to your database when it is generated. First check whether it already exists, and save it only if it doesn’t. There are two main reasons:
- You can send to many users from your own route handler without using the console.
- Apps like chat need to look up a user’s token on the server to notify them when a new message arrives. If you don’t store tokens, you don’t know where to send.
Follow Firebase’s own Concepts and best practices in its Cloud Messaging documentation, especially the guidance on managing registration tokens, so your notifications keep reaching users.
Common mistakes and gotchas
| Symptom | Likely cause |
|---|---|
| iOS app crashes at launch | Missing GoogleService-Info.plist |
| No iOS notifications arrive | APNs .p8 key, Key ID or Team ID not uploaded to Firebase, or testing on a simulator instead of a real device |
Lost the .p8 file |
It can only be downloaded once. Create a new key |
| Android registration fails | Package name doesn’t match the Capacitor appId, or google-services.json is missing |
| Console error: service worker not found | Missing firebase-messaging-sw.js in the public folder |
| No token generated on web | Permission denied or blocked, or the VAPID key is missing |
| Test message doesn’t arrive | The token changed. Copy the latest one, and remember tokens are per device and browser |
| Service worker won’t run locally | Service workers need HTTPS |
| Nothing shows on Windows | OS or Chrome notification settings turned off |
| Clicking opens a new tab every time | Relative URL used. Use full absolute URLs |
| Duplicate foreground notifications | Listener not cleaned up. Return the unsubscribe function from useEffect |
| Secret key leaked | service-key.json committed to a repository. Rotate it and keep it out of git |
Frequently asked questions
Is Firebase Cloud Messaging free?
Yes, FCM is a free Google service provided by Firebase, though some features, such as uploading images in the console, may need a paid plan.
Do push notifications work on iOS and Android browsers?
Web push now works on iOS and Android browsers, including Safari, which wasn’t true for iOS for a long time. With the native SDKs, the same FCM setup also pushes native notifications.
What is a VAPID key?
It is a public key that authenticates push subscribers so they only receive messages from servers holding the matching private key. It is used to generate each browser’s unique token.
What is the difference between foreground and background notifications?
In the foreground, your app code handles the message (for example with a toast). In the background, the service worker shows a system notification.
Why do I need a service worker?
It runs in the browser separately from your page, so it can receive and show notifications even when the page is closed.
Can I send notifications without the Firebase console?
Yes. Use the Firebase Admin SDK from a trusted environment such as a Cloud Function or a Next.js route handler.
Why do I need to store tokens?
Without stored tokens you can’t know where to send a notification outside the console, for example when a specific user receives a chat message.
Can I target specific users?
Yes. You can send to one device, many devices, a topic, or segments connected to Google Analytics.
Conclusion
Push notifications with FCM follow the same pattern on every platform: create a Firebase project, register your app, ask the user for permission, get a device token, and send a message to that token from the console or a trusted server. The details differ, with Xcode capabilities and an APNs key on iOS, a matching package name and google-services.json on Android, and a VAPID key plus a service worker on the web.
Start with the Firebase console to confirm everything works, then move to sending from your own backend, save your users’ tokens, and use the foreground and background handlers to give each situation the right experience. Once that is in place, you can add targeting, scheduling and analytics, and turn notifications into a real engagement channel.