How I Added Modern Siri Shortcuts to an Expo React Native App
How I added modern Siri Shortcuts to an Expo React Native app
Adding Siri Shortcuts to a React Native app sounds straightforward until the implementation crosses three boundaries at once: Apple’s native App Intents framework, Expo’s generated iOS project, and a JavaScript runtime that may not be ready when the intent fires.
That was the situation in Fermento.
I wanted someone to be able to say, “Hey Siri, start a Kombucha brew,” and land in the app with the correct fermentation timer started. I also wanted the shortcuts to be available immediately after installation, without asking people to record phrases manually.
The obvious React Native path—installing an older Siri shortcut library—was not a safe option. The useful APIs had moved on, older SiriKit UI integrations were becoming brittle on newer iOS versions, and an App Intent cannot simply call arbitrary JavaScript while running in the background.
The solution was not one bridge. It was a relay race:
- A native Swift App Intent receives the request.
- A shared App Group stores a small payload.
AppDelegateconsumes that payload when iOS opens the app.- A custom URL hands the action to React Native.
- A state-aware JavaScript handler waits until the app is ready and then runs the same business logic as the UI.
The result keeps Fermento in an Expo-managed workflow while using Apple’s modern App Intents framework directly.
Why App Intents instead of legacy SiriKit bridges
Apple introduced App Intents in iOS 16 as the modern way to expose app actions to Siri, Shortcuts, Spotlight, and other system experiences.
For Fermento, this meant defining native actions such as StartBrewIntent and OpenFermentoIntent as Swift types conforming to AppIntent. The action for starting a batch accepts a brewType parameter backed by an AppEnum, with values such as Kombucha, Jun, and Kimchi.
The important addition is AppShortcutsProvider. It publishes supported shortcuts and invocation phrases to the system, so they can be discoverable without the old “record a voice phrase” flow.
A simplified version looks like this:
import AppIntents
@available(iOS 16.0, *)
struct StartBrewIntent: AppIntent {
static let title: LocalizedStringResource = "Start Brew"
static let description = IntentDescription(
"Starts a new fermentation batch."
)
@Parameter(title: "Brew Type", default: .kombucha)
var brewType: BrewTypeEnum
static var openAppWhenRun: Bool = true
@MainActor
func perform() async throws -> some IntentResult {
// Persist the request for the app to consume.
return .result()
}
}
The provider then connects the intent to phrases that include both the app name and the selected parameter:
@available(iOS 16.0, *)
struct FermentoShortcutsProvider: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: StartBrewIntent(),
phrases: [
"Start a new \(.applicationName) \(.$brewType) brew",
"Start a \(.$brewType) brew in \(.applicationName)"
],
shortTitle: "Start Brew",
systemImageName: "leaf.fill"
)
}
}
From the user’s perspective, the shortcut already exists. The interesting problem begins after Siri recognizes the phrase.
The native-to-JavaScript gap
An App Intent can run while the main React Native application is inactive. At that moment, there may be no mounted component tree, no hydrated providers, and no JavaScript function available to call.
Opening a custom scheme such as fermento:// directly from an OpenURLIntent was not a reliable escape hatch either. In this context, Apple’s URL-opening path expects a supported URL, and using a custom scheme can trigger an assertion failure.
So the intent does not try to execute the React Native action. It leaves a message for the app.
Fermento uses a shared App Group container:
private let fermentoAppGroupID = "group.sk.fermento.app"
private let pendingTypeKey = "pendingStartBrewTypeId"
private let pendingTimestampKey = "pendingStartBrewTimestamp"
When StartBrewIntent runs, it writes two values to the shared UserDefaults suite:
- the selected brew identifier, such as
kombucha; - the current timestamp.
@MainActor
func perform() async throws -> some IntentResult {
let typeId = brewType.rawValue
if let defaults = UserDefaults(suiteName: fermentoAppGroupID) {
defaults.set(typeId, forKey: pendingTypeKey)
defaults.set(
Date().timeIntervalSince1970,
forKey: pendingTimestampKey
)
defaults.synchronize()
}
return .result()
}
Because openAppWhenRun is enabled, iOS then brings Fermento to the foreground.
This handoff is deliberately small. UserDefaults is not being used as an event bus or a second application state. It carries one short-lived command across a process boundary.
Keeping native code inside an Expo-managed workflow
Fermento is managed with Expo, but App Intents require native Swift code and changes to the iOS application lifecycle.
Editing the generated Xcode project manually would work once and then become a maintenance trap. The next expo prebuild could regenerate the native project and remove those changes.
Instead, Fermento uses a custom Expo Config Plugin named withAppIntents.js. During prebuild, the plugin places the required Swift implementation into the generated iOS project and modifies AppDelegate.swift with the handoff logic.
This treats native customization as reproducible build configuration rather than an undocumented patch. The generated project remains disposable; the plugin is the source of truth.
The plugin adds a method similar to routePendingStartBrewShortcut. It runs during both relevant lifecycle paths:
didFinishLaunchingWithOptionsfor a cold start;applicationWillEnterForegroundfor a warm start.
The method reads the shared payload, rejects stale requests, clears the command before dispatching it, and turns it into a deep link:
func routePendingStartBrewShortcut(_ application: UIApplication) {
guard
let defaults = UserDefaults(
suiteName: "group.sk.fermento.app"
),
let typeId = defaults.string(
forKey: "pendingStartBrewTypeId"
)
else { return }
let timestamp = defaults.double(
forKey: "pendingStartBrewTimestamp"
)
let age = Date().timeIntervalSince1970 - timestamp
guard age <= 10 else {
defaults.removeObject(forKey: "pendingStartBrewTypeId")
defaults.removeObject(forKey: "pendingStartBrewTimestamp")
return
}
defaults.removeObject(forKey: "pendingStartBrewTypeId")
defaults.removeObject(forKey: "pendingStartBrewTimestamp")
defaults.synchronize()
guard let url = URL(
string: "fermento://?intent=start-brew&typeId=\(typeId)"
) else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
application.open(url, options: [:])
}
}
The ten-second window matters. Without it, an interrupted or previously unconsumed command could start a batch later when the app happens to open for an unrelated reason.
Clearing the payload before opening the URL gives the command consume-once behavior. If the lifecycle callback runs again, it does not replay the same intent.
The short cold-start delay solves a different race: iOS may launch the application before React Native has attached its URL listener. Waiting briefly gives the bridge time to initialize.
A deep link is not the finish line
Once AppDelegate opens the custom URL, expo-linking can deliver it to JavaScript. But “JavaScript is running” does not mean “the application is ready to start a brew.”
Fermento still has to load its data, hydrate timer state, mount providers, and clear the splash flow. Triggering the business action too early could reference unavailable brew types or dispatch into an incomplete state tree.
An invisible root-level component, SiriDeepLinkHandler, owns this boundary.
It listens for links such as:
fermento://?intent=start-brew&typeId=kombucha
Then it:
- parses the intent and
typeId; - stores the request if the app is not ready;
- waits for data, timers, and startup UI to finish loading;
- validates the identifier against the brew types currently available in the app;
- calls the same internal action used by the normal interface.
This is less like handling navigation and more like maintaining a one-item intent queue. The URL is only the envelope. The handler decides when its contents are safe to execute.
That distinction made the implementation much more robust. Deep links often arrive at the least convenient moment: during a cold start, while providers are hydrating, or before the root navigator is settled.
Reusing business logic instead of automating the UI
After the readiness gates pass, the handler invokes the internal timer action through Fermento’s timer hook. It does not simulate a tap or navigate through screens hoping that a component side effect will do the work.
This keeps Siri and the visible UI as two entry points into the same application behavior:
Siri phrase → App Intent → shared payload → deep link → triggerBrew
UI button → component event → triggerBrew
That shared endpoint is important. If starting a batch later gains validation, analytics, persistence, or notification scheduling, both paths can inherit the change.
Why the old “Add to Siri” flow was removed
Older React Native Siri integrations often wrap APIs such as INUIAddVoiceShortcutViewController. That model belongs to the previous generation of SiriKit shortcut donation and user-recorded phrases.
In Fermento, keeping that UI path created more risk than value on modern iOS versions. Native exceptions can cross the bridge badly, and a failure in an old shortcut controller is much worse than a missing convenience screen.
The app therefore does not ask a legacy bridge to create a voice shortcut. Its App Intents are already registered by AppShortcutsProvider.
When someone wants to inspect or customize shortcuts, the useSiri.ts hook opens Apple’s Shortcuts app:
Linking.openURL('shortcuts://')
The native system remains responsible for shortcut management, while Fermento owns the actions it exposes.
Home Screen Quick Actions use the same routing boundary
Siri is not the only fast entry point.
Fermento also uses expo-quick-actions for Home Screen long-press items such as “New Brew” and “Check Batches.” These actions are configured through Expo and handled near the same root-level routing component.
{
"plugins": [
[
"expo-quick-actions",
{
"iosActions": [
{
"id": "new_brew",
"title": "New Brew",
"icon": "plus"
},
{
"id": "check_batches",
"title": "Check Batches",
"icon": "time"
}
]
}
]
]
}
The transport differs—Siri uses an App Intent and App Group handoff, while a Home Screen action comes through the quick-action API—but both eventually reach a centralized, state-aware routing boundary.
That prevents every external entry point from inventing its own startup logic.
The architecture in one flow
“Hey Siri, start a Kombucha brew”
↓
StartBrewIntent.perform()
↓
App Group UserDefaults
(typeId + timestamp)
↓
openAppWhenRun
↓
AppDelegate lifecycle callback
↓
stale check + consume-once clear
↓
fermento://?intent=start-brew&typeId=kombucha
↓
SiriDeepLinkHandler
↓
readiness gates + type validation
↓
shared triggerBrew action
The analogy I keep coming back to is an airport baggage handoff. Siri does not carry the bag all the way to the hotel room. It tags the payload and hands it to the next controlled checkpoint. Each layer accepts responsibility only when it is ready, validates what it received, and passes it forward once.
What I would keep from this implementation
The most useful lessons are broader than Siri:
- Use the native system’s current extension framework. Avoid forcing legacy SiriKit abstractions onto App Intents.
- Treat generated native projects as build artifacts. In Expo, put repeatable native modifications in a Config Plugin.
- Pass commands across runtime boundaries as small, expiring payloads. A
typeIdand timestamp were enough. - Consume before dispatch. Clear pending commands before opening the deep link to prevent replay.
- Model startup as a readiness problem. A received URL is not proof that application state is prepared.
- Keep external entry points thin. Siri, quick actions, and UI events should converge on shared business logic.
- Let the operating system own shortcut management. The app should expose App Intents, not recreate Apple’s Shortcuts UI.
Final result
Fermento now exposes zero-configuration Siri actions through Apple’s modern App Intents framework while preserving an Expo-managed React Native setup.
The native layer does what only the native layer can do: register intents, receive Siri requests, participate in the iOS lifecycle, and transport a short-lived payload safely. The JavaScript layer waits until the application is ready, validates the command, and runs the existing fermentation logic.
No deprecated shortcut-recording modal. No fragile attempt to call React Native from a background intent. No permanent manual edits to the generated Xcode project.
Just a sequence of small handoffs with clear ownership at every boundary.
If you want to try the result, download Fermento on the App Store, open Apple’s Shortcuts app, and look for Fermento’s actions.