iOS Live Activities with Expo and React Native, APNs-First
Build the WidgetKit target, observe ActivityKit tokens, and prepare a production-ready client — without calling `Activity.request`.
Most iOS Live Activity tutorials begin with the application calling Activity.request. That approach is useful for local demos, but it avoids the harder production requirement: starting an activity when the user is not inside the app.
In this implementation, the app never starts the Live Activity. It observes ActivityKit tokens and registers them with a backend. The backend sends start, update, and end events through Apple Push Notification service (APNs). iOS creates and renders the experience—even if the React Native process is not running.
We will build the client side of that architecture with Expo SDK 57, React Native, a Widget Extension, and a small local Expo Module. In Part 2, we will send the real APNs requests, test the killed-app path, and debug the failures that often return a successful response but display nothing.
The architecture: APNs is the source of truth
The responsibility split is intentionally strict:
- The app observes the push-to-start token and each activity’s update token, then sends them to your API.
- Your backend owns the lifecycle and sends
event: start,event: update, orevent: end. - APNs and ActivityKit create, update, and end the Live Activity.
- The Widget Extension renders the Lock Screen and Dynamic Island UI from the latest content state.
The sequence looks like this:
- The app receives a
pushToStartTokenand registers it with your backend. - Your backend sends a
startpayload to that token. - ActivityKit creates the Live Activity and issues a
pushToUpdateToken. - The app observes that update token and registers it for the relevant order.
- Your backend sends subsequent updates and the final end event.
The golden rule: the production path contains no
Activity.requestcall and no JavaScript “Start activity” button.
What you need
- Xcode 16 or newer and CocoaPods 1.16.2 or newer
- An Apple Developer account with an APNs Auth Key, Team ID, and Key ID
- A physical iPhone running iOS 17.2 or newer for push-to-start
- An Expo development build; Expo Go cannot load this native module or widget target
- A unique iOS bundle identifier that belongs to your Apple Developer team
The sample also uses React Native Firebase Messaging to expose the regular FCM and APNs device tokens. That integration is convenient for the FCM transport shown in Part 2, but the ActivityKit token flow itself does not depend on Firebase.
1. Configure Expo for Live Activities
Install a development client and the target generator:
npx expo install expo-dev-client @bacons/apple-targetsThen configure the iOS app. Replace the bundle identifier and Team ID with values from your own account:
{
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.example.delivery",
"appleTeamId": "XXXXXXXXXX",
"infoPlist": {
"NSSupportsLiveActivities": true,
"NSSupportsLiveActivitiesFrequentUpdates": true,
"UIBackgroundModes": ["remote-notification"]
},
"entitlements": {
"aps-environment": "development"
}
},
"plugins": [
"expo-dev-client",
"@bacons/apple-targets",
"./plugins/withLiveActivities"
]
}NSSupportsLiveActivities is not optional. When it is missing, the rest of the code can build and the push can appear healthy, yet no Live Activity is displayed.
A small config plugin makes the Info.plist requirement survive Continuous Native Generation:
// plugins/withLiveActivities.js
const { withInfoPlist } = require('expo/config-plugins');module.exports = (config) =>
withInfoPlist(config, (next) => {
next.modResults.NSSupportsLiveActivities = true;
next.modResults.NSSupportsLiveActivitiesFrequentUpdates = true;
return next;
});Create the widget target and generate the native iOS project:
npx create-target widget
npx expo prebuild -p ios --clean
npx expo run:ios --deviceKeep source-of-truth changes in app.json, config plugins, and targets/. Editing only the generated ios/ directory is temporary; the next clean prebuild will remove those edits.
2. Define one ActivityKit schema
A Live Activity has fixed attributes and a dynamic content state. For a delivery order, the fixed value is the order ID. Status, title, subtitle, and progress can change:
// targets/widget/_shared/OrderAttributes.swift
import ActivityKit
import Foundationstruct OrderAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var status: String
var title: String
var subtitle: String
var progress: Double
}
var orderId: String
}The type name is part of the wire protocol. The start payload must contain "attributes-type": "OrderAttributes" exactly. A different spelling or module-qualified name can produce the most frustrating failure mode in this workflow: APNs accepts the request, but ActivityKit cannot decode the payload and renders nothing.
Get Vinicius Petrachin’s stories in your inbox
Join Medium for free to get updates from this writer.
With @bacons/apple-targets, files under targets/widget/_shared/ can be linked into the app and extension targets. Keep the ActivityAttributes definition identical wherever it is compiled.
3. Build the Lock Screen and Dynamic Island UI
The Widget Extension renders directly from context.attributes and context.state. There is no React Native view inside the Dynamic Island:
ActivityConfiguration(for: OrderAttributes.self) { context in
VStack(alignment: .leading, spacing: 8) {
Text(context.state.title).font(.headline)
Text(context.state.subtitle).font(.subheadline)
ProgressView(value: context.state.progress)
}
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: iconName(for: context.state.status))
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.title)
}
DynamicIslandExpandedRegion(.trailing) {
Text("\(Int(context.state.progress * 100))%")
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.subtitle)
ProgressView(value: context.state.progress)
}
} compactLeading: {
Image(systemName: iconName(for: context.state.status))
} compactTrailing: {
Text("\(Int(context.state.progress * 100))%")
} minimal: {
Image(systemName: iconName(for: context.state.status))
}
}The target needs SwiftUI, WidgetKit, and ActivityKit. A deployment target of iOS 16.2 is sufficient for basic Live Activities; remote push-to-start requires iOS 17.2 or later on a physical device.
4. Add a read-only Expo Module for tokens
React Native cannot subscribe to ActivityKit’s asynchronous Swift sequences directly, so create a local Expo Module:
npx create-expo-module@latest --local live-activitiesThe module should expose observations, not lifecycle commands:
areActivitiesEnabled()getPushToStartToken()getActiveActivities()startObservingTokens()pushToStartTokenDidChangepushToUpdateTokenDidChange
ActivityKit returns token bytes as Data. APNs expects the token as a hexadecimal string:
private func hexString(from data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}Observe both token streams:
for await data in Activity<OrderAttributes>.pushToStartTokenUpdates {
sendEvent("pushToStartTokenDidChange", [
"pushToStartToken": hexString(from: data)
])
}for await activity in Activity<OrderAttributes>.activityUpdates {
for await data in activity.pushTokenUpdates {
sendEvent("pushToUpdateTokenDidChange", [
"activityId": activity.id,
"pushToUpdateToken": hexString(from: data),
"orderId": activity.attributes.orderId
])
}
}There is one production-critical addition: when the module starts, iterate over Activity<OrderAttributes>.activities and attach an update-token observer to every existing activity. That covers activities created while the app was force-quit. Waiting only for activityUpdates can miss an already-running activity when the user launches the app later.
5. Keep the React Native layer intentionally boring
The JavaScript screen should display and register tokens. It should not control the ActivityKit lifecycle:
const startSub = addPushToStartTokenListener(({ pushToStartToken }) => {
console.log('[LiveActivity] pushToStartToken:', pushToStartToken);
// POST it to your backend, associated with this device.
});
const updateSub = addPushToUpdateTokenListener((event) => {
console.log('[LiveActivity] pushToUpdateToken:', event);
// POST activityId, orderId, and pushToUpdateToken to your backend.
});A practical API shape is:
POST /devices/push-to-startstores a push-to-start token per installation.POST /activities/:orderId/update-tokenstores the update token for one Live Activity.
On a physical phone, localhost refers to the phone—not your development machine. Use your Mac’s LAN address for a local API, or point the build at a reachable HTTPS environment.
6. Understand the four iOS push tokens
Token confusion causes a large share of failed Live Activity tests:
- FCM registration token: identifies the app installation to Firebase Cloud Messaging.
- APNs device token: delivers ordinary notification pushes.
- Push-to-start token: starts a new Live Activity for an ActivityAttributes type.
- Push-to-update token: updates or ends one specific Live Activity.
They are not interchangeable. A start request targets the push-to-start token. An update or end request targets the update token created for that activity.
If you use React Native Firebase, request notification permission with expo-notifications and read the FCM/APNs device tokens with Messaging. ActivityKit tokens still come from the native module. Regular background message handlers do not receive Live Activity pushes; the system consumes those pushes before JavaScript runs.
Client-side validation checklist
- The app and widget extension are signed by the same Apple Developer team.
- The generated Info.plist contains
NSSupportsLiveActivities = true. - The app’s entitlement contains the expected
aps-environment. - The physical device is on iOS 17.2 or later and Live Activities are enabled in Settings.
- The full push-to-start token appears in logs as hexadecimal.
- The widget and native module compile an identical
OrderAttributesschema. - No production code calls
Activity.request.
Next: send the APNs start, update, and end events
The client is now ready, but the Island should not appear yet. That is the desired state: only a backend push can start it.
Continue with Part 2: Start, Update, and End iOS Live Activities with APNs →
Part 2 includes direct APNs HTTP/2 requests, ES256 provider authentication, the FCM alternative, a complete killed-app test, and a field guide to silent 200 OK failures. In addition to the github link if you want to use it :) Feel free to do so.










