Rich Push Notifications: Images, Actions, and Deep Links with FCM

A hands-on guide to setting up FCM for both iOS and Android, with client and server-side code.

Beyond Basic Text Notifications

A plain title-and-body notification is the starting point, not the destination. Rich notifications — images, action buttons, and deep links straight to relevant content — meaningfully improve engagement when used deliberately.

Sending an Image Notification

await admin.messaging().send({
  token: userToken,
  notification: {
    title: 'Your order has shipped!',
    body: 'Track your package in real time.',
  },
  android: {
    notification: {
      imageUrl: 'https://cdn.example.com/order-shipped.png',
    },
  },
  apns: {
    fcmOptions: {
      imageUrl: 'https://cdn.example.com/order-shipped.png',
    },
  },
});

Adding Action Buttons (Android)

await admin.messaging().send({
  token: userToken,
  notification: { title: 'New message from Sam', body: 'Hey, are you free tonight?' },
  android: {
    notification: {
      clickAction: 'OPEN_CHAT',
      channelId: 'messages',
    },
  },
  data: { screen: 'chat', chatId: '4821' },
});
// Registering the notification channel with actions (Android)
class MessagingService : FirebaseMessagingService() {
    override fun onMessageReceived(message: RemoteMessage) {
        val intent = Intent(this, ChatActivity::class.java).apply {
            putExtra("chatId", message.data["chatId"])
        }
        val replyAction = NotificationCompat.Action.Builder(
            R.drawable.ic_reply, "Reply", buildReplyPendingIntent(message)
        ).build()

        val notification = NotificationCompat.Builder(this, "messages")
            .setContentTitle(message.notification?.title)
            .setContentText(message.notification?.body)
            .addAction(replyAction)
            .setContentIntent(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE))
            .build()

        NotificationManagerCompat.from(this).notify(message.hashCode(), notification)
    }
}

Deep Linking to the Right Screen

// Flutter: handling notification taps and routing
FirebaseMessaging.onMessageOpenedApp.listen((message) {
  final screen = message.data['screen'];
  final id = message.data['chatId'];

  switch (screen) {
    case 'chat':
      Navigator.pushNamed(context, '/chat', arguments: id);
      break;
    case 'orderDetails':
      Navigator.pushNamed(context, '/orders/${message.data['orderId']}');
      break;
  }
});

Handling Cold-Start Deep Links

When a user taps a notification and the app wasn’t running, you need to capture the initial message separately from the foreground/background listeners:

final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
if (initialMessage != null) {
  handleNotificationNavigation(initialMessage.data);
}

Silent Data-Only Notifications

For background sync without displaying anything to the user (updating cached data, refreshing a badge count), send a data-only payload with no notification block:

await admin.messaging().send({
  token: userToken,
  data: { type: 'sync', updatedAt: Date.now().toString() },
  android: { priority: 'high' },
  apns: { headers: { 'apns-priority': '5' } },
});

Conclusion

Rich notifications and reliable deep linking are what separate a genuinely useful notification system from one users learn to ignore. Design the data payload carefully upfront — it’s what determines whether a tap actually lands the user somewhere relevant.