How to Add Push Notifications to an Expo App with Supabase Edge Functions (iOS & Android)
Getting expo push notifications wired to a Supabase backend without OneSignal or Firebase is completely achievable. This guide walks the full stack: token registration, Database Webhooks, and Edge Function dispatch to APNs and FCM.
How to Add Push Notifications to an Expo App with Supabase Edge Functions (iOS & Android)
Getting expo push notifications wired to a Supabase backend without reaching for OneSignal or Firebase is completely achievable, but the path from "it works on my machine" to reliable delivery on both iOS and Android is littered with subtle traps. This guide walks through the full stack: registering device tokens in Supabase, triggering a Supabase Edge Function via a Database Webhook, and fanning notifications out to APNs (Apple Push Notification service) and FCM (Firebase Cloud Messaging), all from infrastructure most Expo developers already have running.
Why skip the third-party notification services?
Services like OneSignal and Firebase Cloud Messaging are perfectly good tools. OneSignal in particular handles analytics, segmentation, and multi-channel delivery well. But adding them to a Supabase-backed Expo app means introducing a third vendor's SDK, a third secret store, and a third dashboard to monitor. For teams that have already invested in Supabase, the question worth asking is: can the same Supabase infrastructure handle this?
The answer is yes. Supabase Edge Functions run on a Deno-compatible runtime and can make outbound HTTP requests to any API, including the Expo Push API. The Expo push notification service sits in front of both APNs and FCM, so a single POST to https://exp.host/--/api/v2/push/send reaches both platforms without writing platform-specific server code. That is the core of the approach described here: store tokens in Postgres, trigger an Edge Function on a database event, call the Expo Push API from that function.
The benefit is fewer moving parts. Token storage, auth, and notification dispatch all live in the same Supabase project. Row Level Security handles access control on token data. Database Webhooks replace the need for a separate job queue or polling loop. For an indie app or a small team, this is a leaner architecture than adding OneSignal on top of Supabase.
Setting up the token table and registration hook
The first concrete step is building a place in Postgres to store push tokens. A minimal table only needs a handful of columns: a reference to auth.users, the expo_push_token string itself, the platform (ios or android), and a created_at timestamp. Add a unique constraint on (user_id, expo_push_token) so that re-registrations are idempotent, an upsert with onConflict will update the timestamp without duplicating the row.
create table public.push_tokens (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade,
token text not null,
platform text check (platform in ('ios', 'android')),
created_at timestamptz default now(),
unique (user_id, token)
);
Enable RLS and add a policy so users can only read and write their own rows. On the client side, the registration hook should call Notifications.getExpoPushTokenAsync() from expo-notifications, then upsert the result into this table via the supabase-js client. The hook itself is a useEffect that fires on every app launch, not just on first install. More on why that matters in the gotchas section.
The getExpoPushTokenAsync call accepts a projectId argument. From Expo SDK 49 onward, passing the projectId from app.json (or EAS project config) explicitly is required for standalone builds. Skipping it is a common source of null tokens that only surface in production, not in development.
Writing the Edge Function
The Edge Function is the server-side piece that receives a webhook payload and dispatches the notification. Supabase Edge Functions are TypeScript files deployed via the Supabase CLI and run on a Deno-compatible runtime. The simplest version reads the inserted row from the webhook body, queries the recipient's push token from the push_tokens table, and posts to the Expo Push API.
// supabase/functions/push/index.ts
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send";
Deno.serve(async (req) => {
const payload = await req.json();
const record = payload.record; // the newly inserted notifications row
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
const { data: tokens } = await supabase
.from("push_tokens")
.select("token")
.eq("user_id", record.user_id);
if (!tokens || tokens.length === 0) {
return new Response("no tokens", { status: 200 });
}
const messages = tokens.map((t) => ({
to: t.token,
title: record.title,
body: record.body,
data: record.data ?? {},
}));
const res = await fetch(EXPO_PUSH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(messages),
});
return new Response(await res.text(), { status: res.status });
});
Store the EXPO_ACCESS_TOKEN as a Supabase project secret and pass it in the Authorization header when calling the Expo Push API. Expo's Enhanced Security for Push Notifications mode requires this token and will reject requests that omit it. Set the secret via the CLI: supabase secrets set EXPO_ACCESS_TOKEN=your_token. The function reads it at runtime with Deno.env.get("EXPO_ACCESS_TOKEN").
Deploy with supabase functions deploy push. Supabase Edge Functions support global deployment, so the function runs close to the request origin regardless of where the Supabase project is hosted.
Connecting the Database Webhook
With the function deployed, navigate to the Database Webhooks section of the Supabase dashboard. Create a new webhook, set the table to notifications, tick the INSERT event, select Supabase Edge Functions as the webhook type, and choose the push function. Add the service role key as an Authorization header. Leave the timeout at 1000ms for typical notification workloads, longer timeouts are unnecessary because the Expo Push API responds quickly under normal conditions.
The webhook fires synchronously after the insert completes. That means any application code that inserts a row into the notifications table, whether from the client via RLS-gated writes or from a Postgres function, will trigger a push notification to the affected user without any additional wiring. This is the key advantage of the Database Webhook approach over a database trigger: the logic lives in a TypeScript function that is easy to iterate on, not in a PL/pgSQL procedure buried in the database.
To test the full path before touching the mobile app, insert a row directly in the Supabase table editor. If the token is valid and the device is awake, the notification should arrive within a few seconds. If it does not, check the Edge Function logs in the Supabase dashboard, they capture both console.log output and any uncaught exceptions from the Deno runtime.
Four gotchas that will burn you
1. Simulators do not receive push notifications. The iOS Simulator has limited push support. Even with newer Xcode versions that claim simulator push capability, the Expo getExpoPushTokenAsync call will fail or return an unusable token in the simulator context. Always test push notification registration and receipt on a physical device. Build a development client with eas build --profile development --platform ios and install it on a real phone before declaring anything working.
2. Push tokens rotate, re-register on every app launch. Tokens are not permanent identifiers. APNs and FCM can both issue a new token for the same device after an OS update, an app reinstall, or other platform events. The upsert pattern described earlier exists precisely because of this. If the app only registers the token at first install, a rotated token will cause silent delivery failures that are extremely difficult to diagnose because the Expo Push API returns a DeviceNotRegistered error rather than an exception. Call getExpoPushTokenAsync and upsert the result on every cold start.
3. iOS silently drops malformed APNs payloads. APNs is unforgiving about payload structure. A notification that reaches the Expo Push API successfully can be dropped by APNs without any error returned to the caller if the payload violates size limits (4KB total), contains unsupported fields, or uses a content-available flag incorrectly. The Expo Push API abstracts much of this, but custom data payloads that serialize to large objects are a common culprit. Keep data payloads small and test iOS delivery independently from Android. The Expo push notifications tool at expo.dev is useful for quick validation.
4. Android requires explicit notification channels. From Android 8.0 (API level 26) onward, all notifications must be assigned to a channel. Expo's managed workflow creates a default channel automatically, but if the app uses expo-notifications to present local notifications or to customize sounds and vibration patterns, it needs to create channels explicitly using Notifications.setNotificationChannelAsync before dispatching or receiving any notifications. Missing channels on Android cause notifications to be silently discarded at the OS level, with no error surfacing to application code.
Token rotation and silent APNs drops account for the majority of "push notifications stopped working" reports in Expo communities. Both are preventable with a consistent registration strategy and small payloads.
Scaffolding the notification UX before wiring the backend
One practical challenge with notification flows is that the UX, the permission prompt, the opt-in screen, the token-storage hook, needs to exist in the app before any backend work can be tested on device. Building that scaffold by hand for every prototype wastes time that could go toward validating whether the feature even belongs in the product.
This is where RNBlocks is genuinely useful as a first step. Describe a multi-screen app that includes a notification permission flow ("a task manager app with an onboarding flow that requests notification permissions, a home screen with a task list, and a settings screen with notification preferences"), and RNBlocks generates a tappable React Native prototype running in the browser, including the permission request screen, the token-storage hook stub, and the settings toggle for enabling or disabling notifications. The output is clean, downloadable React Native and Expo code, so the permission-flow scaffold can be lifted directly into the real project and the getExpoPushTokenAsync call and Supabase upsert logic added on top.
The value here is separating UX decisions, where the permission prompt appears, what copy it uses, how the settings screen is structured, from backend decisions about webhook timing and payload shape. Getting the UX right on device with a real tappable prototype before writing a single Edge Function avoids the classic mistake of discovering the permission prompt fires at the wrong moment only after the full backend is wired up. RNBlocks' Studio plan (at rnblocks.dev/studio) handles the multi-screen flow generation needed for this kind of prototype.
Wrapping up
Adding push notifications to an Expo app backed by Supabase does not require a third-party notification service. The full stack, from token registration to Edge Function dispatch to APNs and FCM delivery, can run entirely within the Supabase project with a small amount of TypeScript. Three takeaways worth keeping:
- Always test on a real device. Simulators produce misleading results for token registration and notification receipt. A development build installed on physical hardware is the only reliable test environment.
- Upsert tokens on every app launch. Token rotation is silent and inevitable. An idempotent upsert on cold start costs nothing and prevents hard-to-diagnose delivery failures.
- Keep payloads small and channels explicit. iOS drops oversized or malformed APNs payloads without surfacing errors to the caller; Android discards notifications with no channel assigned. Both failures are invisible without deliberate testing on each platform.
Ready to build?
Describe the app and get a live React Native prototype in minutes.