Peakiq Blog
Adding an iOS Control Center Widget With Deep Linking to a React Native App
Learn how to add an iOS Control Center widget to your React Native app using WidgetKit and App Intents for seamless deep linking to specific app screens.
iOS 18 opened up Control Center to third-party apps, and it's a genuinely underused surface. Most teams treat Control Center as an Apple-only space — timers, flashlight, screen recording — and never consider that a single tap from there could drop a user straight into a specific screen of their own app. For an app where certain actions are reached for constantly throughout the day, that's not a minor convenience. It removes an entire app-launch-and-navigate cycle.
We recently added this to a React Native app where field users needed fast access to one particular screen, dozens of times a day, often with their hands full. Below is how the whole thing fits together — the widget extension, the App Intent that drives it, the entitlements that make it possible, and the deep link that hands control back to the React Native side.
Step 1: Add a Widget Extension Target
Control Center controls live inside a WidgetKit extension, not the main app target. This has to be added from Xcode directly — there's no way to do it from the React Native / CocoaPods side.
- Open the
.xcworkspace(not.xcodeproj) for the React Native app in Xcode. - Go to File → New → Target…
- In the template picker, select Widget Extension under the iOS tab.
- Name it — this becomes both the target name and, by default, the bundle ID suffix.
ComplyStationControlsin this example. - Check Include Control Widget in the options that follow. This is what scaffolds a
ControlWidgetalongside the usual timeline-based widget code — without it, Xcode only gives you a home-screen widget. - When prompted to activate the new scheme, choose Activate. Xcode will also ask whether to embed the extension in the main app target — accept this, since a widget extension only ships as part of the app that hosts it.
- Xcode generates a new folder with a
Bundle.swift, aControl.swift(or similarly named) file, anInfo.plist, and a.entitlementsfile already wired into the new target's build settings. These generated files are what get replaced with the ones below — the scaffold is mostly boilerplate meant to be overwritten. - Delete anything Xcode generated for a timeline-based widget view (the
TimelineProvider,SimpleEntry, and widgetViewstruct) if the goal is a Control Center button only, since those aren't used here. - Confirm the new target's Deployment Target is iOS 18.0 or later —
ControlWidgetandAppIntent-driven controls don't exist before iOS 18, and a lower deployment target will fail to compile once the code below is added.
Once the target exists, the generated Info.plist for the extension just declares it as a WidgetKit extension:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>Nothing to customize here — this is boilerplate every widget extension needs.
Step 2: Define the Control Widget Itself
A ControlWidget is what actually renders as a button in Control Center. It needs a unique kind identifier and an AppIntent to run when tapped:
//
// ComplyStationControlsControl.swift
// ComplyStationControls
//
import AppIntents
import SwiftUI
import WidgetKit
struct ComplyStationControlsControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.peakiq.PEAKIQ.ComplyStationControls"
) {
ControlWidgetButton(action: OpenAppIntent()) {
Label("Chemical Inventory", image: "Chemical")
}
}
}
}The kind string is the widget's stable identity — reverse-DNS style, scoped to your bundle ID, and it should never change once shipped, since the system uses it to track the widget across updates.
Every widget extension needs a bundle entry point, same as the main app has @main on its App struct:
//
// ComplyStationControlsBundle.swift
// ComplyStationControls
//
import WidgetKit
import SwiftUI
@main
struct ComplyStationControlsBundle: WidgetBundle {
var body: some Widget {
ComplyStationControls()
ComplyStationControlsControl()
}
}Step 3: Write the App Intent That Deep Links Back Into the App
This is the part doing the actual work. OpenAppIntent needs to do two things when tapped: tell the main app which screen to open, and actually open the app via a custom URL scheme.
//
// OpenAppIntent.swift
// ComplyStationControlsExtension
//
import AppIntents
struct OpenAppIntent: AppIntent {
static let title: LocalizedStringResource = "Open PEAKIQ"
static var openAppWhenRun = true
static var isDiscoverable = true
private let appGroupId = "group.peakiq.widget"
private let siriEntityKey = "siri_selected_entity"
func perform() async throws -> some IntentResult & OpensIntent {
let entity = "open:chemicalinventory"
if let defaults = UserDefaults(suiteName: appGroupId) {
defaults.set(entity, forKey: siriEntityKey)
}
let encodedValue = entity.addingPercentEncoding(
withAllowedCharacters: .urlHostAllowed
)!
let url = URL(string: "peakiq://\(encodedValue)")!
return .result(opensIntent: OpenURLIntent(url))
}
}Two mechanisms are doing the work here, deliberately in parallel rather than relying on just one:
- Shared
UserDefaultsvia an app group. The target screen identifier is written to storage the main app can also read, ahead of the app actually opening. This matters because the app might already be running in the background — a cold-start deep link handler alone wouldn't catch that case. - A custom URL scheme.
OpenURLIntentis what actually triggers the app to open (or come to the foreground), carrying the same encoded value as the URL host. On the React Native side, this is picked up by the standardLinkingAPI exactly like any other deep link.
Both are set to the same value, so however the RN app ends up checking — a fresh Linking.getInitialURL() call or reading the app group defaults on AppState change — it lands on the correct screen.
Step 4: Share Data Across Targets With an App Group
Widget extensions run in their own sandboxed process, separate from the main app. They can't just read the main app's UserDefaults directly — that's what the app group entitlement is for. It needs to be added to both the main app target and the widget extension target, with matching identifiers:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.peakiq.widget</string>
<string>group.peakiq.shared</string>
</array>
</dict>
</plist>Skipping this step is the single most common reason a Control Center widget silently fails to hand off state to the main app — the intent runs fine, the URL opens fine, but UserDefaults(suiteName:) on either side quietly returns nil because the entitlement isn't wired up on one of the two targets.
Step 5: Handle the Deep Link on the React Native Side
With the native half done, the React Native app just needs its usual Linking handling — nothing control-widget-specific about it:
import { Linking } from 'react-native';
useEffect(() => {
const handleUrl = ({ url }) => {
const path = url.replace('peakiq://', '');
if (path === 'open:chemicalinventory') {
navigation.navigate('ChemicalInventory');
}
};
Linking.getInitialURL().then((url) => {
if (url) handleUrl({ url });
});
const subscription = Linking.addEventListener('url', handleUrl);
return () => subscription.remove();
}, []);Because the identity link (peakiq://open:chemicalinventory) and the app-group fallback carry the same encoded entity string, this handler is the only place routing logic needs to live, regardless of whether the app was cold-launched or already running.
The Real Lesson Here
None of the individual pieces here are complicated — a widget target, an intent, an entitlement, a URL scheme. What makes Control Center integrations easy to get wrong is that they span two separate processes that don't share memory, only a sandboxed group container and a URL scheme. Test each handoff path independently: cold launch from a terminated state, foreground resume from backgrounded, and first-run before the entitlement has ever been exercised. Any one of those three quietly failing looks identical from the outside — the button just does nothing.
If you're adding this to your own app, this is the order that avoids backtracking:
- Add the widget extension target and the bundle entry point.
- Write the
AppIntentwith both the app-group write and the URL scheme. - Add the app group entitlement to both targets before testing anything.
- Wire up
Linkinghandling on the React Native side. - Test cold launch, backgrounded resume, and first-run separately.
The payoff is disproportionate to the setup cost — a single tap from Control Center, no unlocking into the app first, straight to the screen someone actually needed.