Push Notifications React Native 2026: Expo, FCM v1 & APNs

Hướng dẫn triển khai push notifications React Native 2026 với expo-notifications, FCM v1 HTTP API và APNs HTTP/2: cấu hình credentials, đăng ký token, background handler, deep linking và troubleshooting các lỗi thường gặp.

Push React Native 2026: FCM v1 & APNs Setup

Cập nhật: 24 Tháng 5, 2026

Push Notifications trong React Native 2026 được triển khai chuẩn nhất qua thư viện expo-notifications kết hợp với FCM v1 HTTP API cho Android và APNs HTTP/2 cho iOS. API legacy FCM đã bị Google ngắt từ 20/06/2024, nên mọi backend cũ buộc phải migrate sang OAuth 2.0 service account. Bài này sẽ đi qua cấu hình credentials, đăng ký push token, gửi notification từ server Node.js, xử lý background handler, deep linking và rich notification cho cả Android 13+ lẫn iOS 17+.

  • FCM HTTP Legacy API đã bị shutdown từ 20/06/2024. Bắt buộc dùng FCM v1 HTTP API với OAuth 2.0 service account JSON.
  • Android 13 (API 33) trở lên yêu cầu runtime permission POST_NOTIFICATIONS, phải request bằng Notifications.requestPermissionsAsync().
  • iOS dùng APNs HTTP/2 với khóa .p8 (Token-based). Expo tự xử lý nếu bạn upload key qua EAS Credentials.
  • Background handler bắt buộc dùng TaskManager.defineTask đăng ký ở top-level (ngoài component) trong file index.js.
  • Expo Push Service (Expo Push Tokens bắt đầu bằng ExponentPushToken[...]) là wrapper miễn phí, gửi tối đa 600 notifications/giây mỗi project.
  • Notification Channels (Android 8+) phải được tạo trước khi gửi. Nếu thiếu, hệ thống sẽ đẩy vào channel "Miscellaneous" mặc định và giảm độ ưu tiên.

Kiến trúc Push Notifications trong React Native 2026

Thành thật mà nói, push notification là một trong những phần khó debug nhất của React Native, vì nó dính tới ba lớp khác nhau: device (app đăng ký push token), provider (FCM cho Android, APNs cho iOS), và backend (server gửi payload tới provider qua HTTPS). Với expo-notifications, Expo bỏ thêm một lớp middleware nữa tên là Expo Push Service. Bạn chỉ gửi một request HTTP duy nhất tới https://exp.host/--/api/v2/push/send, và Expo sẽ tự forward sang FCM hoặc APNs đúng nền tảng.

Năm 2026 có hai thay đổi cực kỳ quan trọng so với giai đoạn 2023. Thứ nhất, FCM Legacy HTTP API (endpoint fcm.googleapis.com/fcm/send với header Authorization: key=<server_key>) đã bị Google ngừng hỗ trợ từ 20/06/2024 theo thông báo trong Firebase Cloud Messaging migration guide. Backend cũ phải chuyển sang FCM v1 HTTP API (endpoint fcm.googleapis.com/v1/projects/{PROJECT_ID}/messages:send) sử dụng OAuth 2.0 bearer token sinh từ service account JSON. Thay đổi thứ hai: Android 13+ buộc app phải xin runtime permission POST_NOTIFICATIONS. Nếu không request, notification sẽ bị OS chặn âm thầm, không có exception, không có log. Tôi đã mất nguyên một buổi chiều mới phát hiện ra điều này khi ship app lên Pixel 7.

Khi dùng Expo, bạn không cần code riêng cho FCM v1 hay APNs HTTP/2. Chỉ cần upload service account JSON (Android) và APNs key .p8 (iOS) qua eas credentials rồi gửi qua Expo Push Service là xong. Nếu cần kiểm soát sâu hơn (ví dụ phân tích delivery report chi tiết theo từng region), bạn vẫn có thể gọi thẳng FCM v1 và APNs từ backend Node.js bằng firebase-admin@parse/node-apn.

Cài đặt và cấu hình expo-notifications

Bắt đầu bằng cách cài đặt thư viện trong dự án Expo SDK 55. Lưu ý nhỏ nhưng quan trọng: bạn phải dùng development build hoặc bare workflow, vì Expo Go từ SDK 53 đã không còn nhận remote push notification trên Android do hạn chế của Google Play Console. Nếu bạn còn đang chạy SDK cũ hơn, đọc qua bài nâng cấp Expo SDK 55 trước rồi quay lại đây.

npx expo install expo-notifications expo-device expo-constants

Tiếp theo cấu hình app.json để khai báo plugin và icon notification cho Android. Cái này bắt buộc, vì Android Studio sẽ từ chối icon có màu khác trắng (đây là quy định của Material Design từ Android 5.0):

{
  "expo": {
    "name": "MyApp",
    "slug": "my-app",
    "android": {
      "googleServicesFile": "./google-services.json",
      "package": "com.example.myapp"
    },
    "ios": {
      "bundleIdentifier": "com.example.myapp",
      "infoPlist": {
        "UIBackgroundModes": ["remote-notification"]
      }
    },
    "plugins": [
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#1e88e5",
          "defaultChannel": "default",
          "sounds": ["./assets/notification-sound.wav"]
        }
      ]
    ]
  }
}

Sau khi cập nhật app.json, chạy npx expo prebuild --clean để regenerate native code, rồi build development client bằng eas build --profile development --platform all. Đừng quên upload FCM service account JSON và APNs key bằng lệnh eas credentials. Nếu bỏ qua bước này, Expo Push Service sẽ trả về DeviceNotRegistered ngay từ request đầu tiên (và bạn sẽ nghi oan code mình bị lỗi, như tôi đã từng).

Đăng ký Push Token và xin quyền

Việc đăng ký token nên tách thành ba bước riêng biệt: kiểm tra physical device, xin quyền notification, và lấy ExponentPushToken. Simulator iOS không bao giờ nhận được push token thật. Đây là lý do phổ biến nhất khiến developer nghĩ code bị lỗi. Tham khảo thêm cách quản lý quyền nhạy cảm trong bài xác thực sinh trắc học React Native.

import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';

Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowBanner: true,
    shouldShowList: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
  }),
});

export async function registerForPushNotificationsAsync(): Promise<string | null> {
  if (!Device.isDevice) {
    console.warn('Push notifications cần thiết bị thật, không chạy trên simulator');
    return null;
  }

  if (Platform.OS === 'android') {
    await Notifications.setNotificationChannelAsync('default', {
      name: 'Thông báo mặc định',
      importance: Notifications.AndroidImportance.HIGH,
      vibrationPattern: [0, 250, 250, 250],
      lightColor: '#1e88e5',
      sound: 'default',
    });
  }

  const { status: existingStatus } = await Notifications.getPermissionsAsync();
  let finalStatus = existingStatus;

  if (existingStatus !== 'granted') {
    const { status } = await Notifications.requestPermissionsAsync({
      ios: {
        allowAlert: true,
        allowBadge: true,
        allowSound: true,
        allowProvisional: false,
      },
    });
    finalStatus = status;
  }

  if (finalStatus !== 'granted') {
    console.warn('Người dùng đã từ chối quyền nhận notification');
    return null;
  }

  const projectId = Constants.expoConfig?.extra?.eas?.projectId
    ?? Constants.easConfig?.projectId;

  if (!projectId) {
    throw new Error('Không tìm thấy EAS projectId, chạy `eas init` để tạo');
  }

  const token = (await Notifications.getExpoPushTokenAsync({ projectId })).data;
  return token;
}

Khi đã có ExponentPushToken[xxxxxxx], bạn lưu token vào database backend kèm userId, platformappVersion. Token có thể bị OS thu hồi (uninstall, clear data, restore từ backup), nên backend cần xử lý lỗi DeviceNotRegistered từ Expo Push Service và xóa token cũ ngay lập tức. Việc giữ token đã chết trong DB không chỉ phình bảng mà còn lãng phí quota khi gửi batch hàng triệu thông báo.

Gửi notification từ server với FCM v1 và APNs

Cách đơn giản nhất là gửi qua Expo Push Service. Bạn không cần code riêng FCM hay APNs. Đây là code Node.js dùng SDK expo-server-sdk (phiên bản 3.10+ hỗ trợ FCM v1 ngầm định):

import { Expo, ExpoPushMessage, ExpoPushTicket } from 'expo-server-sdk';

const expo = new Expo({
  accessToken: process.env.EXPO_ACCESS_TOKEN,
  useFcmV1: true,
});

export async function sendPushNotifications(
  tokens: string[],
  title: string,
  body: string,
  data: Record<string, unknown> = {},
) {
  const messages: ExpoPushMessage[] = [];

  for (const pushToken of tokens) {
    if (!Expo.isExpoPushToken(pushToken)) {
      console.error(`Token không hợp lệ: ${pushToken}`);
      continue;
    }

    messages.push({
      to: pushToken,
      sound: 'default',
      title,
      body,
      data,
      channelId: 'default',
      priority: 'high',
      badge: 1,
      ttl: 3600,
    });
  }

  const chunks = expo.chunkPushNotifications(messages);
  const tickets: ExpoPushTicket[] = [];

  for (const chunk of chunks) {
    try {
      const ticketChunk = await expo.sendPushNotificationsAsync(chunk);
      tickets.push(...ticketChunk);
    } catch (error) {
      console.error('Lỗi gửi notification chunk:', error);
    }
  }

  return tickets;
}

Nếu cần gọi thẳng FCM v1 (ví dụ khi không dùng Expo Push Service), bạn dùng firebase-admin với service account JSON đã tạo từ Firebase Console > Project Settings > Service Accounts. Theo tài liệu chính thức FCM v1, OAuth bearer token phải được refresh mỗi giờ. May mắn là firebase-admin tự xử lý cache này, bạn không cần làm gì thêm:

import admin from 'firebase-admin';
import serviceAccount from './service-account.json' assert { type: 'json' };

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount as admin.ServiceAccount),
});

export async function sendFcmV1(fcmToken: string, title: string, body: string) {
  const message: admin.messaging.Message = {
    token: fcmToken,
    notification: { title, body },
    android: {
      priority: 'high',
      notification: { channelId: 'default', sound: 'default' },
    },
    apns: {
      payload: { aps: { alert: { title, body }, sound: 'default', badge: 1 } },
      headers: { 'apns-priority': '10' },
    },
  };

  return admin.messaging().send(message);
}

Xử lý background handler và data-only payload

Notification có hai dạng: notification message (hiển thị banner và đánh thức app khi user tap) và data message (payload âm thầm, app xử lý ở background mà không hiển thị gì). Để xử lý data message khi app bị kill, bạn phải đăng ký task qua TaskManagertop-level của index.js hoặc App.tsx. Tuyệt đối không đặt trong useEffect, vì khi JS bundle khởi động ở background, root component có thể chưa kịp mount.

import * as Notifications from 'expo-notifications';
import * as TaskManager from 'expo-task-manager';

const BACKGROUND_NOTIFICATION_TASK = 'BACKGROUND-NOTIFICATION-TASK';

TaskManager.defineTask(BACKGROUND_NOTIFICATION_TASK, ({ data, error }) => {
  if (error) {
    console.error('Lỗi background notification:', error);
    return;
  }
  console.log('Nhận data notification ở background:', data);
});

Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK);

Với iOS, payload cần chứa "content-available": 1 trong apskhôngalert. Với Android, chỉ gửi field data mà không có notification. Lưu ý iOS giới hạn chỉ 2-3 silent push mỗi giờ; vượt quá Apple sẽ throttle thẳng tay. Đây là lý do bạn không nên dùng silent push cho real-time sync, mà nên kết hợp với WebSocket hoặc giải pháp offline-first với outbox pattern để đồng bộ dữ liệu chủ động khi user mở app.

Deep linking và Rich Notification

Mỗi notification nên có data.url chứa deep link, để khi user tap, app điều hướng đến màn hình chính xác (ví dụ chi tiết đơn hàng, message cụ thể). Code listener đặt trong root component, kết hợp với Expo Router để mở route tương ứng. Cách cấu hình deep link đầy đủ được giải thích trong bài Expo Router với deep linking.

import { useEffect } from 'react';
import * as Notifications from 'expo-notifications';
import { router } from 'expo-router';

export function useNotificationNavigation() {
  useEffect(() => {
    const subscription = Notifications.addNotificationResponseReceivedListener(
      (response) => {
        const url = response.notification.request.content.data?.url as string | undefined;
        if (url) {
          router.push(url as never);
        }
      },
    );

    Notifications.getLastNotificationResponseAsync().then((response) => {
      const url = response?.notification.request.content.data?.url as string | undefined;
      if (url) router.push(url as never);
    });

    return () => subscription.remove();
  }, []);
}

Để hiển thị hình ảnh trong notification (Rich Notification), với iOS cần Notification Service Extension viết bằng Swift để tải attachment, còn với Android bạn truyền field image trong android.notification của FCM payload. Expo Push Service hỗ trợ field richContent: { image: 'https://...' }, nhưng image phải nhỏ hơn 1MB và là PNG/JPEG (không hỗ trợ WebP trên iOS < 17). Tôi từng dính bug WebP trên iOS 16.4 hồi cuối năm ngoái, mất hai giờ debug mới ngộ ra nguyên nhân.

Cách test push notification trên Expo Go và development build

Từ Expo SDK 53, remote push notification không còn hoạt động trong Expo Go trên Android, do Google Play yêu cầu mỗi app phải có FCM Sender ID riêng. Trên iOS, Expo Go vẫn nhận được notification nhưng dùng push token tạm thời của Expo Go (sẽ thay đổi sau mỗi lần Expo update). Cách test chuẩn cho năm 2026 là dùng development build:

  1. Build dev client: eas build --profile development --platform ios --local (hoặc Android).
  2. Cài app, chạy npx expo start --dev-client và lấy ExponentPushToken bằng code ở phần trên.
  3. Mở Expo Push Notifications Tool, paste token, điền title/body, gửi thử.
  4. Để test background handler, gửi message có _contentAvailable: truekhông có title/body. App phải in log "Nhận data notification ở background" trong Xcode Console hoặc adb logcat.

Một lưu ý quan trọng: trên iOS, khi app bị user "swipe kill" từ App Switcher, iOS sẽ không đánh thức app cho silent push nữa cho đến khi user mở lại app ít nhất một lần. Đây là behavior intended của Apple để tiết kiệm pin, không phải bug đâu (nhiều bạn mới làm hay tốn rất nhiều thời gian truy bug "không tồn tại" này).

Troubleshooting các lỗi thường gặp

Bảng dưới đây tổng hợp các lỗi và nguyên nhân thường gặp khi triển khai push notification trong React Native 2026. Nó là kết quả tổng hợp từ chính những lần tôi và team đã "ăn hành" trong production:

Triệu chứng Nguyên nhân thường gặp Cách khắc phục
DeviceNotRegistered User uninstall hoặc clear data Xóa token khỏi DB, request lại khi user mở app
InvalidCredentials FCM service account JSON chưa upload qua eas credentials Chạy eas credentials, chọn Android > FCM V1, upload lại JSON
Notification không hiện trên Android 13+ Chưa request POST_NOTIFICATIONS runtime permission Gọi Notifications.requestPermissionsAsync() sau lần mở app đầu tiên
iOS: token là null trên simulator Simulator không hỗ trợ APNs token thật Test trên thiết bị thật hoặc dùng simulator .apns file
MismatchSenderId google-services.json không khớp với android.package Tải lại JSON từ Firebase Console đúng package name
Background handler không chạy TaskManager.defineTask đặt trong useEffect Chuyển defineTask ra top-level của index.js

Câu hỏi thường gặp

Có cần Firebase khi dùng Expo Notifications không?

Có. Đối với Android bạn vẫn cần tạo Firebase project và upload google-services.json cùng service account JSON, vì Expo Push Service forward request qua FCM v1. Trên iOS thì không cần Firebase, vì Expo gọi trực tiếp APNs HTTP/2.

Sự khác biệt giữa Expo Notifications và FCM là gì?

FCM là provider gốc của Google, chỉ phục vụ Android (và bridge sang APNs cho iOS qua firebase-messaging-ios). Expo Notifications là wrapper cao cấp gọi cả FCM v1 và APNs, cho phép bạn dùng một token ExponentPushToken duy nhất cho cả hai nền tảng, miễn phí tới 600 messages/giây mỗi project.

Vì sao push notification không nhận được trên iOS?

Ba nguyên nhân phổ biến: (1) test trên simulator thay vì thiết bị thật, (2) APNs key .p8 chưa upload qua eas credentials, (3) entitlement aps-environment bị thiếu trong provisioning profile. Cách dễ nhất là chạy lại eas build để regenerate profile.

Làm sao test push notification mà không cần backend?

Sử dụng Expo Push Notifications Tool trên web. Bạn chỉ cần paste ExponentPushToken, điền title/body và bấm Send. Công cụ này gọi trực tiếp Expo Push Service, nên cho phép test toàn bộ flow, kể cả background handler và rich content.

Notification channel trên Android dùng để làm gì?

Từ Android 8.0 (API 26), mọi notification phải thuộc về một channel. User có thể tắt/bật từng channel riêng biệt (ví dụ tắt thông báo marketing nhưng giữ thông báo giao dịch). Nếu app gửi notification mà không khai báo channel, hệ thống tự đẩy vào channel "Miscellaneous" và priority sẽ bị giảm xuống thấp.

Về Tác Giả Devon Nakashima

Devon is a principal engineer who has been writing React Native since the 0.40 days, with fifteen years total in mobile and web. He led the rewrite of the Wealthsimple trading app from native iOS/Android to a shared RN codebase, then spent two years at Discord on the mobile experience team working on the New Architecture migration and the Hermes upgrade that shipped to 200M+ installs. These days he's an independent consultant and contracts mostly with healthtech and developer-tools companies. He's an occasional contributor to React Navigation and was a maintainer on react-native-mmkv for about a year. His writing here is opinionated and tends toward architecture-level decisions: when to drop down to native modules, how to structure feature flags across iOS and Android, and why he thinks Expo's prebuild model finally won the bare-vs-managed debate around 2024.