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.
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-modulesPush 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.
2. Link the EAS project
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 initeas 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 doctorYou 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 iosPick 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).
-
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.devfor your dev build. -
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. -
Tell the build about it - in
app.config.ts, add one line to theandroidblock:app.config.ts android: { googleServicesFile: "./google-services.json", // ...the existing adaptiveIcon, package and other keys stay as they are }, -
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.
-
Upload it to EAS:
bunx eas-cli credentials --platform androidPick 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:
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 --deviceThen start the dev server for it:
bun run start:devYou 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 /settingsThe 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 doctorbun run typecheckEvery 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, orapp.easProjectIdis empty. See The push token is null. - No token on Android -
google-services.jsonis missing, not referenced fromapp.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.InvalidCredentialsmeans the APNs key or FCM V1 key isn't on EAS for this app;DeviceNotRegisteredmeans the token is stale - reopen the app for a fresh one.- Arrives, but the tap opens Home -
--urlmust 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
defaultchannel 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.
Add a paywall
Charge for your Expo app with RevenueCat, Adapty or Stripe Checkout - from store agreements to a sandbox purchase that unlocks a feature.
Add analytics and crash reporting
Wire PostHog or Amplitude and Sentry into an Expo app behind a consent sheet, track your own events, and get readable stack traces.