ReadyNative

Expo Notifications

Set up Expo Notifications for push notifications in an Expo app with ReadyNative: Expo push service + local notifications · dev build required for remote…

Pro

Expo Go: no - dev build required

Ships native code. Make a dev build with bunx expo run:ios / bunx expo run:android (or bunx eas-cli build --profile development).

This gives you remote and local push through Expo's push service: src/lib/push.ts implements the shim, src/hooks/use-push-token.ts is a provider-free store that checks permission and calls getExpoPushTokenAsync({ projectId }) with the id from Constants.expoConfig.extra.eas.projectId, and PushProvider (order 80) installs the foreground handler, creates the Android default channel, and deep-links to data.url when a notification is tapped - including from a cold start. If you set up with --with-examples there's also an example screen at /examples/push with a copyable token and a local test notification. Either way you get a bun run push:test script that sends one message through Expo's API. @/lib/push keeps the same API whichever option you pick - pick this when you want push without running your own APNs and FCM plumbing.

Setup

bun run setup --push expo-notifications
  1. Run bunx eas-cli init. It can't write into the dynamic app.config.ts, so it only prints the project id: paste it into readynative.config.ts → app.easProjectId (or set EAS_PROJECT_ID), and app.config.ts turns it into extra.eas.projectId, which is what the token request needs. With an empty project id the hook reports unsupported and warns in the console.
  2. In the [Apple Developer] portal, create a Push Notifications key, then run eas credentials → [iOS] → [Push Notifications] and upload it. Expo's guide is at https://docs.expo.dev/push-notifications/push-notifications-setup/.
  3. In the [Firebase console], open [Project settings] → [Service accounts], generate a private key, then run eas credentials → [Android] → [FCM V1] and upload the JSON.
  4. Check the Android notification icon. The module adds the config plugin ["expo-notifications", { icon: "./assets/images/android-icon-monochrome.png", color: "#ffffff" }], and the status-bar icon has to be monochrome.
  5. Make a dev build and install it on a physical phone: eas build --profile development, or npx expo run:ios --device. Since SDK 53 Expo Go doesn't receive remote push, and simulators never do.
  6. There are no EXPO_PUBLIC_* keys to paste for this module. To send a test by hand, open https://expo.dev/notifications, paste a token, and send.

Going to production?

The APNs key and the FCM v1 service account are per app, not per build profile - upload them once with eas credentials and every profile uses them. Store the tokens your users register on your own server, and send with data: { url: "/some/route" } so a tap lands people where you meant. Delivery errors show up in Expo's push receipts, not in the send response.

  1. Run bun run doctor - every row for this module should be green.

The things to set up, in one place (no env keys here):

WhatWhere
EAS project ideas init prints it → readynative.config.ts app.easProjectId. https://expo.dev/accounts/[account]/projects
APNs key (iOS)eas credentials → iOS → Push Notifications key. https://docs.expo.dev/push-notifications/push-notifications-setup/
FCM v1 service accountFirebase console → Project settings → Service accounts → upload via eas credentials → Android → FCM V1
Push receipts / dashboardhttps://expo.dev/notifications (paste a token to send a test)

Deps: expo-notifications, expo-device, expo-constants, expo-clipboard, all installed with npx expo install.

Usage

Read the token and the permission status:

import { push } from "@/lib/push";

const { token, status } = push.usePushToken(); // "loading" | "unsupported" | "denied" | "granted"

Ask for permission when it makes sense in your flow, not on first launch:

import { push } from "@/lib/push";

const granted = await push.requestPermission(); // true when granted; a token registers when remote push is possible

push.sendTestNotification({ title, body }) schedules a local notification 3 seconds out (pass a second argument for another delay), asking for permission first; it resolves false when notifications aren't allowed. Settings → Developer tools uses it.

Send one from a server - or from your machine, with the bundled script:

bun run push:test "ExponentPushToken[xxx]" "Title" "Body" --url /settings

That's a POST to https://exp.host/--/api/v2/push/send with { to, title, body, data: { url } }; data.url is the route the app opens on tap.

Gotchas

  • Expo Go can't receive remote push since SDK 53 - no token on Android, and the iOS token only works with Expo's own credentials. The module degrades instead of crashing: status becomes unsupported or denied. Local notifications still work in Expo Go.
  • push:test only reports the ticket. Delivery errors like DeviceNotRegistered or bad credentials show up in the receipts - follow the link the script prints.
  • iOS undetermined and denied both report as denied, since the shim has no separate state. The button prompts in the first case and does nothing in the second.

Check it works - steps 2 to 7 use the example screen, so run this in a tree set up with --with-examples:

  1. Link the EAS project (step 1 of Setup), then eas credentials for the APNs key and FCM v1, then eas build --profile development (or npx expo run:ios --device) and install it on a physical phone.
  2. Open the app → Examples → Push notifications: the status reads "Permission not granted". Tap Allow notifications → the OS dialog → Allow → toast "Push enabled" and an ExponentPushToken[...] appears.
  3. Tap Copy token, then on your computer run bun run push:test "<token>" "Hi" "It works" - it prints Sent. Ticket ….
  4. Background the app: the banner arrives within seconds. Foreground it and send again: the banner still shows, thanks to the foreground handler.
  5. Tap the banner → the app opens on /examples/push from data.url. Kill the app, send again, tap: the same screen from a cold start.
  6. Tap Schedule local test notification in 5s and background the app: the local banner arrives after 5 seconds and takes you back to the screen. This one works in Expo Go too.
  7. Deny permission in the OS settings and reopen: the status reads "Permission not granted", nothing crashes, and the button re-prompts (or points to settings on iOS).

Remove it

While modules/ exists (a tree set up with --keep-modules), setup does all of it:

bun run setup --push none --yes --keep-modules

In a finalized tree setup is a stub, so you undo it by hand. Here is everything this module added:

  1. Delete the files that are still there (the demo screens are gone already unless you set up with --with-examples): scripts/push-test.ts, src/app/examples/push.tsx, src/hooks/__tests__/use-push-token.test.tsx, src/hooks/use-push-token.ts, src/lib/__tests__/push.test.tsx, src/screens/examples/push-example-screen.tsx.
  2. Replace, don't delete src/lib/push.ts: core code imports it, so swap in the no-op version from modules/push/none/files/ of a fresh clone of your tier repo - same exports, nothing behind them.
  3. Uninstall the dependencies: bun remove expo-notifications.
  4. Drop the config plugin expo-notifications from .readynative.json → modules.app.expo.plugins (that is where app.config.ts reads it from), then rebuild the dev build.
  5. Unwrap the provider: delete <PushProvider> and its import from src/providers.tsx.
  6. Delete the script push:test from package.json.
  7. Update the privacy declarations: remove this module's entries from .readynative.json → modules.app.expo.ios.privacyManifests, then re-run bun run gen:privacy and revise your store privacy answers.
  8. Check it: bun run typecheck and bun run lint point at anything that still imports the removed files; bun run gen:graph refreshes docs/ARCHITECTURE.md.

Reference

Everything below is generated from modules/push/expo-notifications/module.json - the same file bun run setup reads, so it is what actually lands in your repo.

Install

bun run setup --push expo-notifications

Module id: push/expo-notifications (the default for this category).

Dependencies

PackageVersionKind
expo-clipboard~57.0.2dependency (expo install)
expo-constants~57.0.19dependency (expo install)
expo-device~57.0.2dependency (expo install)
expo-notifications~57.0.18dependency (expo install)

Config plugins

Merged into app.config.ts through .readynative.json (modules.app):

  • expo-notifications (with options)

Privacy

Play Data safety draft: collects Push token (device id); shared with Expo push service, APNs, FCM. Source of truth: vendor disclosure.

Apple privacy manifest data types (composed into ios.privacyManifests by setup):

TypeLinked to userTrackingPurposes
DeviceIDyesnoAppFunctionality

Providers

Rendered in src/providers.tsx (lower order = outermost):

OrderProviderFrom
80PushProvider@/lib/push

Compatibility

Doctor checks

  • dev build toolchain (Xcode / Android SDK)

After setup

  1. Link an EAS project: bunx eas-cli init prints the project id but cannot write into the dynamic app.config.ts - paste it into readynative.config.ts app.easProjectId (CI: EAS_PROJECT_ID). app.config.ts derives extra.eas.projectId from it; getExpoPushTokenAsync needs it, and until then usePushToken() reports unsupported.
  2. Run eas credentials and upload APNs (iOS) and FCM v1 service-account (Android) keys: https://docs.expo.dev/push-notifications/push-notifications-setup/#get-credentials-for-development-builds
  3. Remote push needs a dev build on a physical device (eas build --profile development or npx expo run:ios|android); Expo Go only delivers local notifications.
  4. On a device: push.requestPermission() then read push.usePushToken().token (or open /examples/push with --with-examples), and run bun run push:test <token> "Title" "Body" (or npm run push:test, pnpm run push:test, yarn push:test - it runs on plain node).

Files

7 files copied to the project root
  • scripts/push-test.ts
  • src/app/examples/push.tsx
  • src/hooks/__tests__/use-push-token.test.tsx
  • src/hooks/use-push-token.ts
  • src/lib/__tests__/push.test.tsx
  • src/lib/push.ts
  • src/screens/examples/push-example-screen.tsx

On this page

Get ReadyNative