ReadyNative
Push notificationsPro

Add push notifications

Set up APNs and FCM through EAS, get an Expo push token on a real phone, send yourself a test push, and open a screen from the tap.

Pro

Hey - by the end of this page your phone shows a push notification you sent from your terminal, and tapping it opens the screen you chose - from the background and from a cold start.

The module does the plumbing: @/lib/push asks for permission and fetches the Expo push token, PushProvider shows notifications while the app is open, creates the Android default channel and deep-links to data.url on tap, and bun run push:test sends one message through Expo's push service. You add the Apple and Google credentials and a place in your UI to ask for permission.

Before you start

  • Tier: Pro. The push module isn't in Free or Starter.
  • Time: about 45 minutes, plus a dev build.
  • Runs in: a dev build on a physical phone. Expo Go can't receive remote push since SDK 53, and simulators never can - see Expo Go or a dev build.
  • Accounts: the Apple Developer Program (iOS), a Firebase project (Android), and a free Expo account - step 2 links the EAS project.
  • Previous tutorial: Add a paywall.

Picked push=expo-notifications at setup? Skip step 1. Step 1 needs modules/, so it works only in a tree set up with --keep-modules; a finalized tree without push starts from a fresh clone - see Can I change a module after setup?.

1. Add the module

bun run setup --push expo-notifications --yes --keep-modules

Push needs a dev build, so setup also switches bun run start to expo start --dev-client.

You should see: src/lib/push.ts, src/hooks/use-push-token.ts and scripts/push-test.ts in your tree, and a push:test script in package.json.

The token request needs your EAS project id. If app.easProjectId in readynative.config.ts is still empty:

bunx eas-cli login
bunx eas-cli init

eas init only prints the id - paste it into app.easProjectId. With an empty id, the push hook reports unsupported and warns in the console. If the project belongs to an organisation, also set app.owner to its slug.

bun run doctor

You should see: "EAS project linked" green.

3. Add the iOS credentials

Apple delivers push through APNs, which needs a key from your developer account. EAS stores the key per bundle id:

bunx eas-cli credentials --platform ios

Pick a build profile, then Push Notifications, and set up a key: sign in with your Apple account and let EAS create one, or upload a .p8 you made in the Apple Developer portal → Certificates, Identifiers & Profiles → Keys with Apple Push Notifications service (APNs) ticked. Your dev build uses the .dev bundle id, so run the command for the development profile as well as production.

You should see: the push key listed for your bundle id when you run the command again.

4. Add the Android credentials

Android delivers push through Firebase Cloud Messaging (FCM v1).

  1. In the Firebase console, create a project and add an Android app for each package that will receive push - your production package from readynative.config.ts, and the same with .dev for your dev build.

  2. Download google-services.json (it covers every Android app in the project) and put it at the repo root. It's configuration, not a secret, and EAS needs it in git to see it.

  3. Tell the build about it - in app.config.ts, add one line to the android block:

    app.config.ts
      android: {
        googleServicesFile: "./google-services.json",
        // ...the existing adaptiveIcon, package and other keys stay as they are
      },
  4. In Firebase, open Project settings → Service accounts → Generate new private key. That JSON is what lets Expo's servers send through FCM - keep it out of git.

  5. Upload it to EAS:

    bunx eas-cli credentials --platform android

    Pick a profile, then Google Service Account → Manage your Google Service Account Key for Push Notifications (FCM V1) → Set up → Upload a new service account key.

You should see: the FCM V1 key listed under your Android app's credentials on expo.dev.

5. Show the token in your app

push.usePushToken() returns { token, status } (status is "loading", "unsupported", "denied" or "granted"), and push.requestPermission() shows the OS prompt. Ask when it makes sense in your flow, not on first launch. Here's a card to drop into Settings or any screen while you test:

src/components/push-card.tsx
import * as Clipboard from "expo-clipboard";

import { Box, Button, Card, Text, toast } from "@/components/ui";
import { push } from "@/lib/push";

export function PushCard() {
  const { token, status } = push.usePushToken();

  const allow = () => {
    push
      .requestPermission()
      .then((granted) => {
        if (!granted) toast.show({ title: "Notifications are off", kind: "error" });
      })
      .catch(() => toast.show({ title: "Couldn't ask for permission", kind: "error" }));
  };

  const copy = (value: string) => {
    Clipboard.setStringAsync(value)
      .then(() => toast.show({ title: "Token copied", kind: "success" }))
      .catch(() => toast.show({ title: "Couldn't copy", kind: "error" }));
  };

  return (
    <Card>
      <Box gap={3}>
        <Text variant="heading">Push notifications</Text>
        <Text variant="caption">Status: {status}</Text>
        {token ? <Text variant="code">{token}</Text> : null}
        {status !== "granted" ? <Button onPress={allow}>Allow notifications</Button> : null}
        {token ? (
          <Button variant="outline" onPress={() => copy(token)}>
            Copy token
          </Button>
        ) : null}
      </Box>
    </Card>
  );
}

For example, render <PushCard /> inside the <Box> of src/screens/home/home-screen.tsx. expo-clipboard came with the module.

You should see: bun run typecheck passing. The card shows up on the dev build in the next step.

6. Build and install on your phone

Credentials and google-services.json are native, so build now. On EAS:

bunx eas-cli build --profile development --platform ios
bunx eas-cli build --profile development --platform android

(iOS installs only on registered devices - bunx eas-cli device:create first.) Or locally with a phone plugged in:

bunx expo run:ios --device

Then start the dev server for it:

bun run start:dev

You should see: the push card with status denied (iOS reports "not asked yet" as denied too). Tap Allow notifications, accept the OS dialog, and an ExponentPushToken[…] appears. Tap Copy token.

7. Send yourself a push

Background the app (or lock the phone), then from the repo root:

bun run push:test "ExponentPushToken[paste-yours]" "Hello" "It works" --url /settings

The arguments are the token, an optional title and body, and --url, the route the app opens on tap. Without --url it opens /examples/push, which exists only with --with-examples. It prints Sent. Ticket ….

You should see: the notification within a few seconds. Tap it and the app opens on Settings. Kill the app, send again and tap: the same screen, from a cold start. With the app open, the banner still shows - that's the foreground handler.

The ticket only means Expo accepted the message. Delivery errors such as DeviceNotRegistered or bad credentials show up in the push receipt, which you fetch with the ticket id - the link the script prints explains how. Or paste the token into expo.dev/notifications to send and inspect from the browser.

Check it

bun run doctor
bun run typecheck

Every push row should be green, and typecheck should pass.

If it doesn't work

  • Status stays unsupported - you're in Expo Go or a simulator, or app.easProjectId is empty. See The push token is null.
  • No token on Android - google-services.json is missing, not referenced from app.config.ts, or has no app for the package you're running (dev builds use .dev). Fix it and rebuild.
  • Sent, but nothing arrives - check the push receipt for the ticket. InvalidCredentials means the APNs key or FCM V1 key isn't on EAS for this app; DeviceNotRegistered means the token is stale - reopen the app for a fresh one.
  • Arrives, but the tap opens Home - --url must be a route that exists, starting with /.
  • Nothing on Android while the app is open for a long time - check the app's notification settings on the phone: the default channel may be muted.

More in Troubleshooting.

Congrats 🎉

You sent a push from your terminal to your phone and landed on a screen from the tap. In production, store each user's token on your server and send with data: { url } from there. To take the module out again, see Expo Notifications → Remove it.

Next, see how people use the app and where it breaks: Add analytics and crash reporting.

On this page

Get ReadyNative