```html SwiftUI: How to Implement Deep Linking (iOS 17+, 2026)

How to Implement Deep Linking in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: onOpenURL Updated: May 12, 2026
TL;DR

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

  1. Typed route enumAppRoute: Hashable is the single source of truth for every destination in the app. Because it's Hashable, it slots directly into a NavigationStack path without any glue code.
  2. URL parsing in the initializerAppRoute(url:) is a failable initializer that inspects url.host and url.pathComponents. Keeping the parsing logic here means onOpenURL stays a one-liner and the routing logic is fully testable in isolation.
  3. 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 NavigationStack guarantees it fires before any child view is rendered.
  4. 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.
  5. #Preview with a pre-built path — passing a constant non-empty path to NavigationStack lets 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

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.

```