| name | push-notifications |
| description | Set up push notifications for iOS and Android using Expo Notifications and Supabase Edge Functions. Use when adding push notifications, configuring APNs/FCM credentials, sending notifications from the backend, or troubleshooting notification delivery. |
Push Notifications
Stack
- expo-notifications — handles token registration, foreground/background receipt on device
- Expo Push Notification Service (EPN) — proxies to APNs (Apple) and FCM (Google); free, no quota
- Supabase Edge Function — sends notifications server-side via Expo Push API
- APNs key — required for iOS production builds (configured in Expo dashboard)
- FCM key — required for Android (configured in Expo dashboard)
You do NOT call APNs or FCM directly. Expo's push service handles the routing.
Step 1: Install
cd mobile
npx expo install expo-notifications expo-device expo-constants
Add to app.json:
{
"expo": {
"plugins": [
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#ffffff",
"sounds": ["./assets/notification-sound.wav"]
}
]
]
}
}
Step 2: Register for Push Token
Add to your app initialization (e.g. app/_layout.tsx):
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
import Constants from "expo-constants";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export async function registerForPushNotifications(): Promise<string | null> {
if (!Device.isDevice) {
console.warn("Push notifications require a physical device");
return null;
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
return null;
}
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = (await Notifications.getExpoPushTokenAsync({ projectId })).data;
return token;
}
projectId comes from eas.json or app.json → expo.extra.eas.projectId. Find it in expo.dev under your project settings.
Step 3: Store Token in Supabase
After registration, save the token to your database:
import { supabase } from "./supabase";
import { registerForPushNotifications } from "./pushNotifications";
export async function syncPushToken(userId: string) {
const token = await registerForPushNotifications();
if (!token) return;
await supabase.from("push_tokens").upsert(
{ user_id: userId, token, platform: Platform.OS },
{ onConflict: "token" }
);
}
Database schema:
create table push_tokens (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users not null,
token text unique not null,
platform text not null,
created_at timestamptz default now()
);
alter table push_tokens enable row level security;
create policy "users manage own tokens" on push_tokens
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
Call syncPushToken after login:
supabase.auth.onAuthStateChange((event, session) => {
if (event === "SIGNED_IN" && session?.user) {
syncPushToken(session.user.id);
}
});
Step 4: Send Notifications from Edge Function
import { createClient } from "npm:@supabase/supabase-js@2";
const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
interface PushMessage {
to: string;
title: string;
body: string;
data?: Record<string, unknown>;
sound?: "default" | null;
badge?: number;
}
export async function sendPushNotification(
userIds: string[],
notification: Omit<PushMessage, "to">
) {
const supabase = createClient(
Deno.env.get("PUBLIC_SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const { data: tokens } = await supabase
.from("push_tokens")
.select("token")
.in("user_id", userIds);
if (!tokens?.length) return;
const messages: PushMessage[] = tokens.map(({ token }) => ({
to: token,
sound: "default",
...notification,
}));
const chunks = chunk(messages, 100);
for (const batch of chunks) {
await fetch(EXPO_PUSH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(batch),
});
}
}
function chunk<T>(arr: T[], size: number): T[][] {
return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
arr.slice(i * size, i * size + size)
);
}
Step 5: Configure APNs Key (iOS Production)
Required for iOS push notifications in production builds.
- developer.apple.com/account/resources/authkeys → +
- Name:
Expo Push, enable Apple Push Notifications service (APNs)
- Download the
.p8 file — download once only
- Note the Key ID and your Team ID (top right of developer.apple.com)
Upload to Expo:
- expo.dev → Your project → Credentials → iOS
- Push Notifications → Upload the
.p8 key, enter Key ID and Team ID
Or via CLI:
eas credentials --platform ios
Step 6: Configure FCM (Android Production)
- Go to console.firebase.google.com
- Create project (or use existing) → Add app → Android
- Enter your package name (
com.yourorg.appname)
- Download
google-services.json → place in mobile/google-services.json
- Project settings → Cloud Messaging → copy Server key
Add to app.json:
{
"expo": {
"android": {
"googleServicesFile": "./google-services.json"
}
}
}
Upload FCM key to Expo:
eas credentials --platform android
Handle Notification Taps
import { useEffect, useRef } from "react";
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
export function useNotificationNavigation() {
const responseListener = useRef<Notifications.Subscription>();
useEffect(() => {
responseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
const data = response.notification.request.content.data;
if (data?.screen) {
router.push(data.screen as string);
}
}
);
return () => {
responseListener.current?.remove();
};
}, []);
}
Pass data: { screen: "/orders/123" } when sending to deep-link on tap.
Testing
npx expo push:send --to "ExponentPushToken[xxx]" --title "Test" --body "Hello"
open https://expo.dev/notifications
Physical device required for testing — simulator/emulator does not receive push notifications.
Gotchas
Notifications work in Expo Go but not in production build — APNs key not uploaded to Expo. Run eas credentials --platform ios and verify Push Notifications key is configured.
Android notifications not delivered — google-services.json missing or wrong package name. Verify package in app.json matches Firebase project.
Token is null on simulator — Expected. Push tokens only work on physical devices. Check with Device.isDevice.
"DeviceNotRegistered" error — Token is stale (user uninstalled/reinstalled app). Delete the token from push_tokens table when this error is returned by Expo Push API.
Notification not shown when app is in foreground — Set shouldShowAlert: true in setNotificationHandler. By default foreground notifications are silent.
iOS notification permission denied — You can only ask for permission once. If denied, user must go to Settings manually. Always explain why before requesting.
Badge count not clearing — Call Notifications.setBadgeCountAsync(0) on app foreground:
AppState.addEventListener("change", (state) => {
if (state === "active") Notifications.setBadgeCountAsync(0);
});