Push Notifications in Capacitor with Firebase Cloud Messaging: A Practical Guide
Wire up @capacitor/push-notifications on Android and iOS: Firebase setup, permissions, token registration, foreground and tap handling, Android channels, and sending a test push from Node.js.
Push notifications are one of those features that look like a single checkbox in a product spec and turn into three days of certificates, JSON files, and “why is nothing arriving?” debugging. Capacitor makes the JavaScript side pleasantly small, but the native plumbing still matters. This guide walks through a clean setup with the official @capacitor/push-notifications plugin and Firebase Cloud Messaging (FCM), and calls out the spots where people usually get stuck.
1. How a push actually reaches your app
Before touching code, it helps to know who talks to whom:
- Your app asks the user for permission and registers with the OS.
- The OS returns a device token (FCM on Android, APNs on iOS).
- Your backend stores that token against the signed-in user.
- FCM receives a send request from your backend and delivers it through Google’s transport on Android, or through Apple Push Notification service (APNs) on iOS.
- The plugin fires JavaScript events when a push arrives in the foreground or when the user taps one.
One detail to remember now, because it saves a lot of confusion later: on iOS, the registration event from @capacitor/push-notifications gives you the APNs device token, not an FCM token. If your backend sends everything through FCM, either send iOS pushes to APNs directly or use a plugin that returns FCM tokens on both platforms, such as @capacitor-firebase/messaging. We'll stick with the official plugin here.
2. Install the plugin
From your Capacitor project root:
npm install @capacitor/push-notifications
npx cap syncThen set how notifications should be presented while the app is open, in capacitor.config.ts:
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.example.pushdemo',
appName: 'Push Demo',
webDir: 'dist',
plugins: {
PushNotifications: {
presentationOptions: ['badge', 'sound', 'alert'],
},
},
};
export default config;If you’d rather fully control foreground display yourself, pass an empty array and show your own UI (or a local notification via @capacitor/local-notifications) inside the pushNotificationReceived listener.
3. Android setup with Firebase
- Create a Firebase project and add an Android app whose package name matches your
appId. - Download
google-services.jsonand drop it intoandroid/app/. - Run
npx cap sync android.
The default Capacitor Android template already applies the Google Services Gradle plugin when google-services.json exists, so you usually don't need to edit Gradle by hand. If your project is older or customized, check that android/app/build.gradle still applies com.google.gms.google-services.
Get Pranjal Vyas’s stories in your inbox
Join Medium for free to get updates from this writer.
Two small Android touches make notifications look professional. First, give FCM a monochrome small icon and a default color in android/app/src/main/AndroidManifest.xml, inside <application>:
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_accent" />Second, on Android 13 (API 33) and later, notifications need the runtime POST_NOTIFICATIONS permission. The plugin's requestPermissions() handles that prompt for you, so you only need to call it at a sensible moment (more on that below).
4. iOS setup with APNs
- In the Apple Developer portal, create an APNs Authentication Key (
.p8) and note its Key ID and your Team ID. - If you’re routing iOS through FCM, upload that key in Firebase Console → Project settings → Cloud Messaging → Apple app configuration.
- Open the iOS project with
npx cap open iosand, on your app target, add the Push Notifications capability. Add Background Modes → Remote notifications too if you plan to send silent/data pushes.
Then forward the native registration callbacks to Capacitor in ios/App/App/AppDelegate.swift:
// Add these two methods inside your existing AppDelegate class
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
NotificationCenter.default.post(
name: .capacitorDidRegisterForRemoteNotifications,
object: deviceToken
)
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(
name: .capacitorDidFailToRegisterForRemoteNotifications,
object: error
)
}Everything else in the AppDelegate stays as it is. For reliable results, test remote pushes on a real iPhone; Simulator support for them is limited.
5. Request permission and register
Wrap the plugin in one small service so the rest of your app never touches it directly. Here’s a framework-agnostic TypeScript version that works the same in Angular, React, or Vue:
import { Capacitor } from '@capacitor/core';
import { PushNotifications } from '@capacitor/push-notifications';
export async function initPush(onToken: (token: string) => Promise<void>) {
if (!Capacitor.isNativePlatform()) {
return; // No native push on the web build
}
let status = await PushNotifications.checkPermissions();
if (status.receive === 'prompt' || status.receive === 'prompt-with-rationale') {
status = await PushNotifications.requestPermissions();
}
if (status.receive !== 'granted') {
console.warn('Push permission not granted');
return;
}
await PushNotifications.addListener('registration', async ({ value }) => {
// FCM token on Android, APNs token on iOS
await onToken(value);
});
await PushNotifications.addListener('registrationError', (err) => {
console.error('Push registration failed', err.error);
});
await PushNotifications.register();
}Register the listeners before calling register(), otherwise a fast token callback can fire before anyone is listening. Also, don't ask for permission on the very first screen. Ask right after the user does something that makes notifications obviously useful, like following an order or enabling reminders. Opt-in rates are noticeably better that way.
6. Handle foreground pushes and taps
There are two different moments to handle: a push arriving while the app is open, and the user tapping a notification from the tray or lock screen.
import { PushNotifications } from '@capacitor/push-notifications';
export async function addPushHandlers(navigate: (path: string) => void) {
await PushNotifications.addListener('pushNotificationReceived', (notification) => {
// App is in the foreground
console.log('Push received', notification.title, notification.data);
});
await PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
// User tapped the notification
const data = action.notification.data ?? {};
if (data.route) {
navigate(String(data.route));
}
});
}
export async function removePushHandlers() {
await PushNotifications.removeAllListeners();
}Put a route key in your push data payload (for example /orders/42) and you get deep linking from notifications almost for free. Keep the payload small and treat it as a hint; fetch the real content from your API after navigation.
7. Create Android notification channels
Android 8.0+ groups notifications into channels, and users can mute each one separately. Create them once at startup:
import { Capacitor } from '@capacitor/core';
import { PushNotifications } from '@capacitor/push-notifications';
export async function createChannels() {
if (Capacitor.getPlatform() !== 'android') {
return;
}
await PushNotifications.createChannel({
id: 'orders',
name: 'Order updates',
description: 'Status changes for your orders',
importance: 4, // High: makes a sound and peeks on screen
visibility: 1, // Public on the lock screen
vibration: true,
});
await PushNotifications.createChannel({
id: 'marketing',
name: 'Offers and news',
importance: 2, // Low: no sound
});
}When you send a push, set the Android channel_id (or channelId in the Admin SDK) to orders or marketing. Splitting transactional and promotional messages early means users mute the offers instead of uninstalling the app.
8. Send a test push from Node.js
With the token from step 5 saved, send a message using the Firebase Admin SDK on your server:
// npm install firebase-admin
const admin = require('firebase-admin');
admin.initializeApp({
credential: admin.credential.cert(require('./service-account.json')),
});
async function sendOrderUpdate(token) {
const messageId = await admin.messaging().send({
token,
notification: {
title: 'Your order is on the way',
body: 'Order #42 left the warehouse.',
},
data: {
route: '/orders/42',
},
android: {
notification: { channelId: 'orders' },
},
apns: {
payload: { aps: { sound: 'default', badge: 1 } },
},
});
console.log('Sent', messageId);
}
sendOrderUpdate(process.argv[2]).catch(console.error);Run it with node send.js <device-token>. Note that data values must be strings in FCM, so stringify numbers and objects before sending. For quick experiments without code, the Messaging section of the Firebase Console can also send a test message to a single token.
9. Debugging checklist
When nothing arrives (and at some point, nothing will), walk through this list:
- No token on Android? Confirm
google-services.jsonis inandroid/app/and the package name matches exactly, then re-runnpx cap sync android. - No token on iOS? Check the Push Notifications capability is on the right target, the provisioning profile includes push, and the two AppDelegate methods are present.
- Token arrives but iOS pushes fail through FCM? Make sure the APNs key is uploaded to Firebase, and that you’re sending to an FCM token, not the raw APNs token.
- Android push arrives but nothing shows? Check the user didn’t deny
POST_NOTIFICATIONS, and that the channel you're targeting exists and isn't muted. - Taps open the app but don’t navigate? Register
pushNotificationActionPerformedearly in app startup so cold-start taps aren't missed. - Tokens go stale. Re-send the token to your backend on every app launch, and delete tokens your server gets
registration-token-not-registerederrors for.
Wrapping up
Capacitor keeps the push notification API refreshingly small: check and request permission, register, listen for the token, and handle two events. The real work lives in the native setup and in treating tokens as data that changes over time. Get those right once, put the logic in a single service, and adding a new notification type becomes a backend change instead of an app release.
Happy shipping, and may your notifications arrive on the first try (they won’t, but now you’ll know why). 🚀










