How to Implement Deep Linking in SwiftUI
Attach .onOpenURL { url in … } to your root view, parse the incoming URL into a typed destination, then write that destination into a @State variable that drives your NavigationStack path. No third-party router needed.
enum AppRoute: Hashable {
case profile(id: String)
case settings
}
@State private var path: [AppRoute] = []
NavigationStack(path: $path) {
ContentView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .profile(let id): ProfileView(id: id)
case .settings: SettingsView()
}
}
}
.onOpenURL { url in
if let route = AppRoute(url: url) {
path.append(route)
}
}
Full implementation
The cleanest pattern is a typed AppRoute enum whose initializer parses any incoming URL — custom scheme or universal link — into a structured destination. A single NavigationStack holds the path array, so appending a route programmatically is identical to a user tap: the same navigationDestination closures handle both cases. All URL handling happens in onOpenURL on the app's root scene, which iOS calls whether the app is cold-starting or already foregrounded.
import SwiftUI
// MARK: - Route
enum AppRoute: Hashable {
case home
case profile(id: String)
case item(id: String)
case settings
/// Parse myapp://profile/abc123 → .profile(id: "abc123")
/// Parse myapp://settings → .settings
init?(url: URL) {
guard url.scheme == "myapp" else { return nil }
let host = url.host ?? ""
let components = url.pathComponents.filter { $0 != "/" }
switch host {
case "profile":
guard let id = components.first else { return nil }
self = .profile(id: id)
case "item":
guard let id = components.first else { return nil }
self = .item(id: id)
case "settings":
self = .settings
default:
return nil
}
}
}
// MARK: - Root App
@main
struct DeepLinkDemoApp: App {
@State private var path: [AppRoute] = []
var body: some Scene {
WindowGroup {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .home:
HomeView()
case .profile(let id):
ProfileView(id: id)
case .item(let id):
ItemView(id: id)
case .settings:
SettingsView()
}
}
}
.onOpenURL { url in
// Reset stack then navigate, or just append — your choice.
if let route = AppRoute(url: url) {
path = [route]
}
}
}
}
}
// MARK: - Screens
struct HomeView: View {
var body: some View {
List {
NavigationLink("My Profile", value: AppRoute.profile(id: "me"))
NavigationLink("First Item", value: AppRoute.item(id: "item-1"))
NavigationLink("Settings", value: AppRoute.settings)
}
.navigationTitle("Home")
}
}
struct ProfileView: View {
let id: String
var body: some View {
Text("Profile: \(id)")
.navigationTitle("Profile")
.accessibilityLabel("Profile for user \(id)")
}
}
struct ItemView: View {
let id: String
var body: some View {
Text("Item: \(id)")
.navigationTitle("Item")
}
}
struct SettingsView: View {
var body: some View {
Text("Settings")
.navigationTitle("Settings")
}
}
// MARK: - Preview
#Preview("Home") {
NavigationStack {
HomeView()
}
}
#Preview("Deep-linked profile") {
NavigationStack(path: .constant([AppRoute.profile(id: "abc123")])) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
if case .profile(let id) = route { ProfileView(id: id) }
}
}
}
How it works
-
Typed route enum —
AppRoute: Hashableis the single source of truth for every destination in the app. Because it'sHashable, it slots directly into aNavigationStackpath without any glue code. -
URL parsing in the initializer —
AppRoute(url:)is a failable initializer that inspectsurl.hostandurl.pathComponents. Keeping the parsing logic here meansonOpenURLstays a one-liner and the routing logic is fully testable in isolation. -
onOpenURL on the root WindowGroup — iOS 17 calls this closure for both cold launches (when the app wasn't running) and foreground opens. Placing it on the outermost
NavigationStackguarantees it fires before any child view is rendered. -
path = [route] vs path.append(route) — resetting the stack (
path = [route]) gives users a clean back-stack that goes Home → Destination; appending is better when the app is already foregrounded and context matters. Choose per UX requirement. -
#Preview with a pre-built path — passing a constant non-empty path to
NavigationStacklets you preview exactly what a deep-linked screen looks like without running the simulator or sending a real URL.
Variants
Universal Links (HTTPS scheme)
Universal Links arrive at onOpenURL with an https scheme and your domain as the host. Update the route parser to accept your production domain alongside the custom scheme, then set up your apple-app-site-association file on your web server.
init?(url: URL) {
// Support both custom scheme and universal link
let isCustomScheme = url.scheme == "myapp"
let isUniversalLink = url.scheme == "https"
&& url.host == "soarias.com"
guard isCustomScheme || isUniversalLink else { return nil }
let parts = url.pathComponents.filter { $0 != "/" }
switch parts.first {
case "profile": self = .profile(id: parts.dropFirst().first ?? "")
case "item": self = .item(id: parts.dropFirst().first ?? "")
case "settings":self = .settings
default: return nil
}
}
Handling deep links inside a tab bar
If your app uses TabView, store both a @State var selectedTab and a per-tab path array. In onOpenURL, switch the active tab first, then append the destination to that tab's path — selectedTab = .profile; profilePath = [route]. This preserves each tab's independent navigation history while still landing the user in exactly the right place.
Common pitfalls
-
Missing URL scheme in Info.plist.
onOpenURLnever fires unless you declare your custom scheme under URL Types → URL Schemes in your target's Info tab. For universal links you need an Associated Domains entitlement instead. Forgetting either of these is the #1 reason deep links silently do nothing. -
Placing onOpenURL on a child view. SwiftUI only propagates the URL to the deepest view that has an
onOpenURLmodifier. If you attach it to a subview that isn't yet in the hierarchy (e.g., because the user hasn't navigated there), the callback is never called. Always attach it to the root scene view. -
Forgetting cold-launch timing. When the app is not running, iOS delivers the URL synchronously as part of the first render pass. If your destination view tries to load data before the route state is set, it may flash an empty state. Initialize
pathfrom the URL before the body renders — or guard navigation-dependent fetches on a non-nil route. -
Accessibility: announce navigation. Screen-reader users benefit from an
AccessibilityAnnouncementwhen a deep link changes the context unexpectedly. Post one viaUIAccessibility.post(notification: .announcement, argument: "Navigated to Settings")insideonOpenURL.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement deep linking in SwiftUI for iOS 17+. Use onOpenURL attached to the root NavigationStack. Support custom URL scheme "myapp://" and universal links on "soarias.com". Parse URLs into a typed AppRoute enum. Make every destination view accessible (VoiceOver labels). Post an accessibility announcement on navigation. Add a #Preview with a realistic pre-built navigation path.
In the Soarias Build phase, drop this prompt into a feature branch — Claude Code will wire up the URL scheme, the route parser, and all destination views in a single pass, leaving you free to focus on screen content rather than navigation plumbing.
Related
FAQ
Does this work on iOS 16?
onOpenURL was introduced in iOS 14 and the data-driven NavigationStack(path:) arrived in iOS 16, so the pattern in this guide technically runs on iOS 16. However, the #Preview macro and some navigationDestination improvements are iOS 17+, and Soarias targets iOS 17+ exclusively. If you need iOS 16 support, replace #Preview with PreviewProvider — everything else is compatible.
How do I test a deep link in Simulator without a real device?
Run xcrun simctl openurl booted "myapp://profile/abc123" in Terminal while your app is running in the Simulator. This fires onOpenURL exactly as a real device would, including the cold-launch path if the app wasn't foregrounded. You can also add a debug toolbar button in a #if DEBUG block that calls UIApplication.shared.open(url) with a hardcoded test URL.
What's the UIKit equivalent?
In UIKit deep links are handled in application(_:open:options:) on your AppDelegate for custom schemes, and application(_:continue:restorationHandler:) for universal links (NSUserActivity). You then imperatively push view controllers onto the relevant UINavigationController. The SwiftUI onOpenURL approach abstracts both entry points behind a single closure and eliminates the imperative view-controller manipulation entirely.
Last reviewed: 2026-05-12 by the Soarias team.