How to Add Push Notifications to Your App with Firebase Cloud Messaging

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.

Diagram: a server or console sends one message through FCM to Android, iOS and web
One message from FCM reaches every platform.

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.
Notification messages are shown by FCM while data messages are handled by your code
Two message types: FCM displays it, or your code decides.

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:

  1. Go to Firebase and open the console.
  2. Click Add project, give it a name and accept the terms. Enabling Google Analytics is optional but useful for targeting.
  3. 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).
  4. Download the config file for your platform (GoogleService-Info.plist for iOS, google-services.json for Android) or copy the config snippet for web.

iOS: native push notifications with Swift

Four steps to set up push notifications on iOS
iOS setup in four steps.

Add the capability and the Firebase SDK

  1. In Xcode, select your project, open Signing & Capabilities, click + Capability and add Push Notifications.
  2. 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.
  3. Drag the GoogleService-Info.plist you 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

AppDelegate.swift
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:

  1. 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.
  2. Download the .p8 file. You can only download it once, so keep it safe.
  3. In the Firebase console, open Project settings > Cloud Messaging and upload the .p8 file 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.

  1. Create an Ionic Angular app and install the plugin:
    npm install @capacitor/push-notifications
    npx cap sync
  2. In capacitor.config.ts, set a unique appId (for example com.example.pushdemo), your app name, and the plugin options:
    plugins: {
      PushNotifications: {
        presentationOptions: ['badge', 'sound', 'alert']
      }
    }
  3. In Firebase, register an Android app. The package name must exactly match your Capacitor appId. Download google-services.json and place it in android/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:

home.page.ts
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

Web push flow: ask permission, get a token with a VAPID key, save it and send to it
From permission to sending: the token flow.

Set up Firebase in React

Create your React app, install Firebase, and add a file that initializes it:

npm install firebase
src/notifications/firebase.js
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.
Foreground messages are handled by the app, background messages by the service worker
Foreground vs background notifications.

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.

A service worker is registered, activated and then listens for push events
The service worker lifecycle.

The service worker code

public/firebase-messaging-sw.js
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.json in 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

app/api/send-notification/route.ts
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

  1. 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.)
  2. 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).
  3. Click Send test message, paste your FCM registration token and click Test.
  4. 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.

Leave a Reply

Your email address will not be published. Required fields are marked *