```html SwiftUI: How to Build Universal Links (iOS 17+, 2026)

How to Build Universal Links in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: Associated Domains, onOpenURL, NavigationStack Updated: May 11, 2026
TL;DR

Add an applinks: Associated Domains entitlement, host a valid apple-app-site-association file on your server, then attach .onOpenURL to your root WindowGroup to receive and route every universal link your app intercepts.

@main
struct MyApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            ContentView(router: router)
                .onOpenURL { url in
                    router.handle(url)
                }
        }
    }
}

@Observable
final class AppRouter {
    var destination: AppDestination?

    func handle(_ url: URL) {
        guard url.host == "www.yourdomain.com" else { return }
        switch url.path {
        case _ where url.path.hasPrefix("/products/"):
            let id = String(url.path.dropFirst("/products/".count))
            destination = .product(id: id)
        case "/profile":
            destination = .profile
        default:
            break
        }
    }
}

Full implementation

Universal Links require both a server-side AASA file and client-side entitlements — the code below focuses on the SwiftUI routing layer. We use an @Observable AppRouter as the single source of truth, pushing destinations onto a NavigationStack path so deep links and in-app navigation share the same system. The router is injected via the environment so any view can read it without prop-drilling.

import SwiftUI

// MARK: - Destination model

enum AppDestination: Hashable {
    case product(id: String)
    case profile
    case order(id: String)
}

// MARK: - Router

@Observable
final class AppRouter {
    var path = NavigationPath()

    func handle(_ url: URL) {
        guard
            let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
            let host = components.host,
            host == "www.yourdomain.com"
        else { return }

        let segments = components.path
            .split(separator: "/", omittingEmptySubsequences: true)
            .map(String.init)

        switch segments.first {
        case "products" where segments.count == 2:
            path.append(AppDestination.product(id: segments[1]))
        case "profile":
            path.append(AppDestination.profile)
        case "orders" where segments.count == 2:
            path.append(AppDestination.order(id: segments[1]))
        default:
            break
        }
    }
}

// MARK: - Root app

@main
struct UniversalLinkDemoApp: App {
    @State private var router = AppRouter()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(router)
                .onOpenURL { url in
                    router.handle(url)
                }
        }
    }
}

// MARK: - Content view

struct ContentView: View {
    @Environment(AppRouter.self) private var router

    var body: some View {
        @Bindable var router = router
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: AppDestination.self) { destination in
                    switch destination {
                    case .product(let id):
                        ProductDetailView(productID: id)
                    case .profile:
                        ProfileView()
                    case .order(let id):
                        OrderDetailView(orderID: id)
                    }
                }
        }
    }
}

// MARK: - Destination views (stubs)

struct HomeView: View {
    var body: some View {
        Text("Home")
            .navigationTitle("Home")
    }
}

struct ProductDetailView: View {
    let productID: String
    var body: some View {
        Text("Product \(productID)")
            .navigationTitle("Product")
            .accessibilityLabel("Product detail for ID \(productID)")
    }
}

struct ProfileView: View {
    var body: some View {
        Text("Profile")
            .navigationTitle("Profile")
            .accessibilityLabel("User profile")
    }
}

struct OrderDetailView: View {
    let orderID: String
    var body: some View {
        Text("Order \(orderID)")
            .navigationTitle("Order")
            .accessibilityLabel("Order detail for ID \(orderID)")
    }
}

// MARK: - Preview

#Preview {
    let router = AppRouter()
    return ContentView()
        .environment(router)
        .onAppear {
            // Simulate an incoming universal link
            if let url = URL(string: "https://www.yourdomain.com/products/abc-123") {
                router.handle(url)
            }
        }
}

How it works

  1. Associated Domains entitlement — In Xcode → Signing & Capabilities, add the Associated Domains capability and enter applinks:www.yourdomain.com. This tells iOS to negotiate with your server at install time and grant the app rights to intercept matching HTTPS links.
  2. Apple App Site Association file — Host a JSON file at https://www.yourdomain.com/.well-known/apple-app-site-association with your team ID, bundle ID, and the path patterns you want to capture (e.g. "/products/*"). iOS fetches this via Apple's CDN — it must be served over HTTPS with no redirect.
  3. .onOpenURL on the WindowGroup — The modifier at the scene level fires for every universal link that iOS routes to your app, whether the app is cold-launched or already foregrounded. Attaching it to the WindowGroup (not a child view) ensures it is always active.
  4. AppRouter.handle(_:) — Parses the URL's path segments, validates the host (prevents spoofing from unrelated URLs), and appends the appropriate AppDestination value to NavigationPath. Because AppRouter is @Observable, SwiftUI automatically re-renders NavigationStack when the path changes.
  5. navigationDestination(for:) — Declared once on the root NavigationStack, it maps every AppDestination case to a concrete view. This keeps routing logic co-located and avoids scattered destination registrations across the hierarchy.

Variants

Passing query parameters to the destination view

// In AppRouter.handle(_:)
case "products" where segments.count == 2:
    let queryItems = components.queryItems ?? []
    let colorParam = queryItems.first(where: { $0.name == "color" })?.value
    path.append(AppDestination.product(id: segments[1], color: colorParam))

// Updated destination enum
enum AppDestination: Hashable {
    case product(id: String, color: String? = nil)
    case profile
    case order(id: String)
}

// In ProductDetailView
struct ProductDetailView: View {
    let productID: String
    var color: String?

    var body: some View {
        VStack {
            Text("Product \(productID)")
            if let color {
                Text("Color: \(color)")
                    .foregroundStyle(.secondary)
            }
        }
        .navigationTitle("Product")
        .accessibilityLabel("Product \(productID)\(color.map { ", color \($0)" } ?? "")")
    }
}

Resetting navigation to root before deep-linking

If your app may already have a deep navigation stack when a new universal link arrives, call router.path.removeLast(router.path.count) inside handle(_:) before appending the new destination. This gives users a clean back-stack rooted at Home rather than stacking destinations unpredictably. Guard it behind a guard !router.path.isEmpty else { ... } check to avoid a crash when the path is already empty.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement universal links in SwiftUI for iOS 17+.
Use Associated Domains entitlement and onOpenURL.
Route incoming URLs to NavigationStack destinations via an @Observable AppRouter.
Make it accessible (VoiceOver labels on all destination views).
Add a #Preview with realistic sample data that simulates a cold-launch universal link.

In the Soarias Build phase, paste this prompt after scaffolding your navigation structure — the generated router slots directly into your existing WindowGroup without restructuring the rest of your view hierarchy.

Related

FAQ

Does this work on iOS 16?

onOpenURL has been available since iOS 14, and NavigationStack since iOS 16, so the routing layer compiles back to iOS 16. However, the @Observable macro requires iOS 17 — swap it for @ObservableObject / @Published if you need iOS 16 support, and adjust the environment injection accordingly.

What's the difference between Universal Links and Custom URL Schemes?

Custom URL schemes (myapp://) are app-only and can be claimed by any app on the device, making them unsafe for sensitive flows. Universal Links use verified HTTPS domains — iOS validates your AASA file server-side before granting interception rights, so another app cannot hijack your links. Universal Links also gracefully fall back to your website in a browser when the app is not installed, giving users a seamless web experience instead of a dead link.

What's the UIKit equivalent?

In UIKit you implement application(_:continue:restorationHandler:) on your UIApplicationDelegate and receive an NSUserActivity with activityType == NSUserActivityTypeBrowsingWeb. Extract the URL from userActivity.webpageURL and route manually. SwiftUI's .onOpenURL handles all of this automatically under the hood and is strongly preferred for SwiftUI-first apps.

Last reviewed: 2026-05-11 by the Soarias team.

```