BUILT INTO EVERY GOODSPEED APP
Push Notifications (End-to-End)
The full push pipeline is pre-built: lazy permission prompt triggered at the 'aha moment', token registration to push_tokens, server-side fan-out via Expo Push API in batches of 100, automatic deletion of DeviceNotRegistered tokens, and per-category opt-out (transactional/product/marketing).
- Tier: Common
- Status: Config-toggled
- Config: features.pushNotifications.enabled
WHY IT MATTERS
Most mobile apps wire up push notifications in one direction: get a token, send a message, hope it arrives. That leaves a graveyard of stale tokens accumulating in your database, users who never opted in getting nagged on the first cold launch, and no diagnostic path when a campaign lands at 0% delivery. The template takes a different architectural position throughout every layer of the pipeline. Permission is requested lazily, deferred until the moment a user first encounters a feature that genuinely benefits from notifications, such as an order status update or a streak reminder, rather than thrown in the face of someone who just installed the app thirty seconds ago. The OS permission dialog only appears once the user has enough context to say yes with intent rather than dismissing out of annoyance. On iOS that first impression is permanent: a denied permission cannot be re-requested from inside the app, only from Settings. Prompting at the right moment meaningfully changes the conversion rate.
Token management is handled end-to-end. The registerForPush() function in services/push.ts requests permission, retrieves the Expo push token, and immediately upserts a row in the push_tokens table containing the user ID, platform, device model, and a preferences JSON column that controls which notification categories the device will receive. The upsert key is expo_push_token, so if a user reinstalls the app or signs in on a second device, the row for that physical device is updated in place rather than duplicated. Server-side fan-out runs through a Supabase Edge Function at supabase/functions/send_push/ that accepts a payload of user IDs, a category, a title, and a body. The function fetches only the tokens for users who have that category preference enabled, batches them at 100 messages per Expo API call (the documented maximum for a single POST), dispatches concurrent batches with a concurrency cap of 5 to avoid overwhelming the API, and for any token that returns a DeviceNotRegistered error it immediately issues a bulk delete against push_tokens. Dead tokens never silently accumulate. Deep-link routing on tap is handled via expo-router: the notification payload carries a deepLink path, a response listener on the client validates that path against a traversal-safety check, and then navigates to it on open. The full pipeline from permission grant to navigated deep link is covered by unit tests in __tests__/services/push.test.ts and integration tests on the Edge Function.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from services/push.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// services/push.ts: registerForPush - permission + token upsert
export async function registerForPush(): Promise<string | null> {
const userId = await getCurrentUserId();
if (!userId) return null;
const permStatus = await requestPermission();
if (permStatus !== 'granted') return null;
const expoPushToken = await getPushToken();
const deviceId = Device.modelName ?? Platform.OS;
await retryWithBackoff(
async () =>
supabase.from('push_tokens').upsert(
{
user_id: userId,
expo_push_token: expoPushToken,
platform: Platform.OS,
device_id: deviceId,
updated_at: new Date().toISOString(),
},
{ onConflict: 'expo_push_token' },
),
{ shouldRetry: isTransientNon4xxError },
);
await captureEvent(EVENTS.push_registered, { platform: Platform.OS });
return expoPushToken;
}Source: goodspeed-apps/gas-template → services/push.ts
HONEST LIMITS
When Push Notifications (End-to-End) is the wrong choice
Expo Push is a best-effort delivery system. Apple APNs and Google FCM each give Expo a delivery ticket in the initial response, but that ticket only tells you the message was accepted by Expo's relay, not that it was delivered to the device. Expo's own receipt API operates on a polling model: you call getReceipts in a separate scheduled job (the companion push-receipt-polling feature covers this with a 5-minute cron Edge Function) rather than receiving a webhook per message. For apps where every notification must provably arrive on the device, or where missed delivery has regulatory or financial consequence, such as trading platforms with time-sensitive price alerts, emergency dispatch systems, or healthcare apps with critical medication reminders, you need a purpose-built push provider that offers per-message delivery receipts with acknowledged failure callbacks. OneSignal, Braze, and a direct APNs/FCM integration with your own delivery ledger are the right alternatives in those scenarios. Beyond delivery guarantees, consider the scale ceiling. The template's fan-out function dispatches up to 5 concurrent batches of 100 tokens each per invocation, which is sufficient for apps with tens of thousands of users. At hundreds of thousands of concurrent push recipients, a single Edge Function invocation will exceed Supabase's 150-second timeout window and needs to be replaced with a queued job-worker pattern. Also note that Expo's push service is a shared infrastructure: during peak hours it can introduce latency of seconds to minutes, which is acceptable for engagement-type notifications but not for transactional messages where a user is waiting on screen. The template is the right starting point for the vast majority of consumer and prosumer apps; the ceiling is well above what most independently-built apps hit during their first growth phase. If your app stores personally identifiable information in the notification payload itself rather than using push as a wake signal that triggers a subsequent data fetch, review your platform privacy policies and any applicable data-residency requirements before enabling this feature, as notification payloads are logged by the OS and may be visible in device diagnostics.
Tier: Common · Config-toggled
Evaluate your use case
Check whether push notifications (end-to-end) aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
The `features.pushNotifications.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Apps built with Push Notifications (End-to-End)
These apps were generated by Goodspeed and use push notifications (end-to-end) as a core part of their experience. Each link goes to the full app marketing page.
CAPABILITIES
Push Notifications (End-to-End) capability breakdown
Concrete dimensions of what the built-in push notifications (end-to-end) implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Permission model | Respects iOS and Android runtime permission status. Re-prompts via a priming screen before the OS dialog. | iOS + Android |
| Token storage | Expo push tokens are upserted to a Supabase push_tokens table keyed by user_id + platform. | Supabase |
| Android channels | Notification channels are created from gasConfig.features.pushNotifications.channels at registration time. | Config-driven |
| Local scheduling | Local notifications can be scheduled for arbitrary future timestamps without a server round-trip. | Built in |
| Web support | All notification functions are no-ops on web. The feature degrades gracefully to zero-error silent behavior. | No-op on web |
COMMON QUESTIONS
Does this work on iOS and Android out of the box?
Yes. The registerForPush() function in services/push.ts branches on Platform.OS and requests the appropriate system permission on both platforms. On Android, notification channels are created from gasConfig.features.pushNotifications.channels at registration time, so you control the sound, vibration pattern, and importance level for each category independently. Channels created this way appear in the device's notification settings as separate toggles, giving users fine-grained OS-level control beyond the in-app category preferences. On iOS, the standard Notifications.requestPermissionsAsync() dialog is triggered, and the returned status string is typed as 'granted' | 'denied' | 'undetermined'. The usePushPermissions hook surfaces this status so you can show a custom explanation screen before re-directing a denied user to iOS Settings. All push functions check whether they are running on web and return null immediately if so, meaning the same component tree renders without error in a web export or in Expo Go without any conditional rendering required from the caller. On physical iOS and Android devices the permission flow completes end-to-end; on the iOS Simulator, Expo cannot retrieve a real push token, so the function returns null gracefully rather than crashing.
How does per-category opt-out work?
Each row in the push_tokens table carries a preferences JSON column with three boolean keys: transactional, product, and marketing. The defaults are defined in DEFAULT_PREFERENCES in services/push.ts: transactional and product default to true, marketing defaults to false. When your server calls the send_push Edge Function with a category field in the payload, the function runs a Supabase query that filters on the category using the JSON column operator preferences->>[category] = 'true', so only tokens with that preference enabled are included in the recipient list. A user who opts out of product updates via your in-app notification settings screen triggers a call to updatePreferences() in services/push.ts, which issues an update against the push_tokens row for the current device token. The change takes effect on the next server-side fan-out. No in-memory cache or session state needs to be invalidated; the query always reads the live preference at send time. You can add additional category keys beyond the three defaults by extending the NotificationCategory type in types/notifications.ts and adding the corresponding key to DEFAULT_PREFERENCES; the fan-out query and the preference update functions pick up the new key automatically without any other changes.
What happens when a device token becomes invalid?
When a user uninstalls the app, resets their device, or revokes app notification permissions at the OS level, Expo's Push API returns a ticket with details.error equal to 'DeviceNotRegistered' for that token. The send_push Edge Function handles this in the sendBatch() inner function: it iterates through every ticket in the Expo response, collects all DeviceNotRegistered token strings into a removed array, and after all batches complete it issues a single bulk DELETE against push_tokens for that set of tokens. This happens synchronously within the same Edge Function invocation that sent the notifications, so the cleanup is immediate and does not require a separate job. For lower-volume apps this inline cleanup is sufficient. For higher-volume scenarios, the push-receipt-polling feature supplements this by running a cron Edge Function that polls Expo's getReceipts API on a 5-minute schedule and settles any remaining delivery rows that were not resolved in the initial send.
Can I trigger push notifications from my own backend, or only from the Supabase Edge Function?
The Edge Function at supabase/functions/send_push/ is the recommended path for all server-triggered notifications because it consolidates batching logic, stale-token cleanup, per-category preference filtering, and audit logging in one place. The invocation interface is a POST with a JSON body containing userIds (array of user UUIDs), category ('transactional' | 'product' | 'marketing'), title, body, and an optional data object that can include a deepLink path for tap routing. Authentication accepts either an x-cron-secret header for automated server-to-server calls (the Edge Function checks this first and short-circuits to the service client path), or a valid admin JWT in the Authorization header. If you need to call it from an external webhook or third-party automation tool that cannot issue a Supabase JWT, set a CRON_SECRET environment variable in your Supabase project secrets and pass it as x-cron-secret. You can also call the Expo Push API directly from any server using tokens stored in push_tokens, but you take on responsibility for batching at the 100-message limit per request, handling DeviceNotRegistered errors, and maintaining the audit trail yourself.
GET IT BUILT INTO YOUR APP