CarPlay
CarPlay uses the same browse + playback handler model as Android Auto. The difference is iOS setup.
1. Enable the plugin flag
{
"expo": {
"plugins": [["react-native-queue-player", { "carplay": true }]]
}
}
This adds the CarPlay audio entitlement, turns on multiple scenes and adds the CarPlay scene to the template's scene manifest, and adds the start-up code described in How the app starts from CarPlay or Siri. It requires Expo SDK 58 or later. Run npx expo prebuild --clean afterwards, and again whenever you change the carplay, siri or chromecast options: a prebuild without --clean adds to the existing native files and does not remove code an option no longer needs (Expo: config plugins and mods).
Bare React Native: there's no plugin — see Bare React Native below.
2. Get Apple approval for the entitlement
The CarPlay audio entitlement (com.apple.developer.carplay-audio) requires approval from Apple before it can be used in a distributed build. Request it for your App ID through your Apple Developer account.
3. Initialize on cold start
CarPlay can attach its scene without your app's UI being shown, so initialize the player and register your handlers from a bootstrap module imported before your app entry — see Setup & manual configuration.
4. Provide a browse tree and resolve play requests
Use setBrowseSnapshot, onBrowseRequest, and onPlayFromIdRequest exactly as described in the Android Auto browse-handlers section. Section icons map to SF Symbols on iOS via the SectionIcon enum.
import { getTrackPlayer, SectionIcon } from 'react-native-queue-player';
getTrackPlayer().setBrowseSnapshot({
rootId: 'root',
sections: [
{ id: 'recent', title: 'Recently played', icon: SectionIcon.RecentlyPlayed, items: [/* … */] },
{ id: 'playlists', title: 'Playlists', icon: SectionIcon.Playlists, items: [/* … */] },
],
});
Notes
- For a local Xcode build without automatic signing, enable the CarPlay capability once in the target's Signing & Capabilities pane. EAS Build handles this automatically.
- Voice playback requests (Siri) are resolved through
onPlayFromSearchRequest— see the Voice guide. - Check the current connection with
isCarConnected()and handle transitions withonCarConnect/onCarDisconnect— the same connection API as Android Auto. - When CarPlay connects while audio is playing, it opens on the Now Playing screen, with your browse lists one Back press away. When nothing is playing (including a queue that is loaded but paused, buffering or loading), it opens on your browse lists. While casting, "playing" is the receiver's state once it has reported one.
How the app starts from CarPlay or Siri
iOS can launch the app for CarPlay, or for a Siri request, without connecting a phone window. Expo SDK 58 starts React Native from the phone window's scene, so without help no JavaScript would run, your browse handlers would never register, and CarPlay would show an empty list. With carplay or siri on, the plugin adds code to the generated AppDelegate.swift and SceneDelegate.swift that keeps a single React root for the app:
- Normal tap on the app icon — unchanged. Expo's
SceneDelegatecreates the window and starts React Native, and the generated code records that window with the library. - CarPlay connects, or Siri asks for the play-media handler, and no React root exists — the library posts
QueuePlayerJavaScriptRequested. The generatedAppDelegatecode starts React Native into a window that is not attached to any scene (it is not on screen), so your bootstrap module runs and registers its handlers. - The phone app is opened afterwards — the generated
SceneDelegatecode moves that same React root into the phone window instead of starting a second one. The app does not remount and playback continues. The same applies when the user swipes the phone UI away while CarPlay keeps the app running, then opens it again. - A second phone window (iPad "New Window", for example) — refused. iOS is asked to destroy the new window's session and bring the existing window forward. A link or activity that opened the refused window is delivered to the running app. iPad still offers "New Window", because Siri and CarPlay need multiple scenes turned on and iOS shows the option for every app that has them. The user sees an empty window open and close, then the existing window returns with playback unaffected. A second window saved by an earlier version of the app and restored by iPadOS is refused the same way.
A link that launches the app normally reaches Linking.getInitialURL() as usual. When the phone window connects after a CarPlay or Siri start, React Native is already running, so a link (or a web-browsing activity) carried by that window's connection, or by a refused second window, arrives as a Linking url event, not through getInitialURL(). React Native drops a url event that arrives before JavaScript has added a url listener, so add the listener from your bootstrap module rather than from a screen.
Notes for CarPlay and Siri launches
- Dev-client Debug builds start through the dev launcher, which waits for Metro. A launch from CarPlay or Siri without the phone app runs your JavaScript only once Metro answers. Release and TestFlight builds are not affected.
initialPropertiesset in your ownSceneDelegateare not applied to a root started for CarPlay or Siri.Appearance.setColorSchemecalled while no phone window exists (a CarPlay-only run) reaches no window, so it has no effect when the phone app opens; call it again once the phone window is showing (for example whenAppStatebecomesactive). A scheme set while a phone window was showing is kept, with the window's background and tint colours, when the root moves into a new phone window.Dimensionsafter a CarPlay or Siri start. React Native watches the key window's frame from the moment it starts, and a start with no phone window has no key window to watch.Dimensionsthen updates when the app becomes active, the device rotates or the appearance changes, and may not follow an iPad window resize on its own.- expo-updates has not been tested with a CarPlay or Siri start.
- iOS 26 deployment target. The window used for a CarPlay or Siri start is created with
UIWindow(frame:), which is deprecated from iOS 26. Apps whose deployment target is iOS 26 or later see one deprecation warning in the generatedAppDelegate.swift.
Bare React Native
There is no plugin; add the entitlement, the scene manifest and the start-up code by hand.
- Add the
com.apple.developer.carplay-audioentitlement and enable the CarPlay capability. - In
Info.plist, turn on multiple scenes and add the CarPlay scene next to the template's phone scene entry (Siri needsUIApplicationSupportsMultipleScenestoo, even without CarPlay):
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>CPTemplateApplicationScene</string>
<key>UISceneConfigurationName</key>
<string>CarPlay</string>
<key>UISceneDelegateClassName</key>
<string>CarPlaySceneDelegate</string>
</dict>
</array>
</dict>
</dict>
- Move the React Native factory out of
SceneDelegate. The React Native 0.88 template creates the factory inSceneDelegate, which only runs once a phone window connects. Create it inAppDelegateinstead, and start React Native into an unattached window when the library asks for JavaScript.QueuePlayerSceneHostis internal to the library's pod, so app code calls its class methods through the Objective-C runtime (replace"HelloWorld"with your module name):
// AppDelegate.swift
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import UIKit
/// Calls a react-native-queue-player QueuePlayerSceneHost class method by selector name.
/// `call` is for selectors that return nothing, `value` for those that return an object.
enum SceneHost {
private static var host: AnyObject? {
NSClassFromString("QueuePlayerSceneHost").map { $0 as AnyObject }
}
static func call(_ name: String, _ argument: Any? = nil) {
_ = host?.perform(NSSelectorFromString(name), with: argument)
}
static func value(_ name: String, _ argument: Any? = nil) -> Any? {
host?.perform(NSSelectorFromString(name), with: argument)?.takeUnretainedValue()
}
}
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var reactNativeDelegate: ReactNativeDelegate?
var reactNativeFactory: RCTReactNativeFactory?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = RCTReactNativeFactory(delegate: delegate)
// Posted on the main thread when CarPlay or Siri needs JavaScript and no React root exists.
NotificationCenter.default.addObserver(
forName: Notification.Name("QueuePlayerJavaScriptRequested"), object: nil, queue: nil
) { [weak self] _ in
MainActor.assumeIsolated {
guard let factory = self?.reactNativeFactory,
(SceneHost.value("hasReactRoot") as? NSNumber)?.boolValue == false
else { return }
let window = UIWindow(frame: .zero)
SceneHost.call("registerHostWindow:", window)
factory.startReactNative(withModuleName: "HelloWorld", in: window, launchOptions: nil)
}
}
return true
}
}
- In
SceneDelegate, refuse a second phone window, move an existing root into the phone window, and otherwise start React Native as the template does and record the window:
// SceneDelegate.swift
import React
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard session.role == .windowApplication,
let windowScene = scene as? UIWindowScene,
let appDelegate = UIApplication.shared.delegate as? AppDelegate
else { return }
if (SceneHost.value("refuseSceneIfDuplicate:", scene) as? NSNumber)?.boolValue == true {
deliver(connectionOptions, from: scene)
return
}
if let adopted = SceneHost.value("adoptRootIntoScene:", windowScene) as? UIWindow {
window = adopted
deliver(connectionOptions, from: scene)
return
}
let window = UIWindow(windowScene: windowScene)
self.window = window
appDelegate.reactNativeFactory?.startReactNative(
withModuleName: "HelloWorld", in: window, connectionOptions: connectionOptions)
SceneHost.call("registerHostWindow:", window)
}
func sceneDidDisconnect(_ scene: UIScene) {
SceneHost.call("forgetRefusedScene:", scene)
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
RCTLinkingManager.scene(scene, openURLContexts: URLContexts)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
RCTLinkingManager.scene(scene, continue: userActivity)
}
// React Native is already running, so the scene's link or activity goes to the running app.
private func deliver(_ options: UIScene.ConnectionOptions, from scene: UIScene) {
if !options.urlContexts.isEmpty {
RCTLinkingManager.scene(scene, openURLContexts: options.urlContexts)
}
options.userActivities.forEach { RCTLinkingManager.scene(scene, continue: $0) }
}
}
If your app handles Home Screen quick actions, pass options.shortcutItem to that handler in deliver as well; otherwise a quick action that opens the phone window after a CarPlay or Siri start is dropped.
If your template's SceneDelegate sets devMenuConfiguration on the factory, move that to AppDelegate with the factory.