SwiftUI Build Guide

How to Add Deep Linking to a SwiftUI App

Route both Universal Links and custom URL schemes straight into specific screens using .onOpenURL and a NavigationStack path — the full setup, from Associated Domains to the app-site-association file.

Last updated: 2026-07-17 10 min read By Ahmed Gagan, iOS Engineer
Quick Answer

SwiftUI deep linking has two transports: custom URL schemes (myapp://) declared in Info.plist under CFBundleURLTypes, and Universal Links (https://) that require the Associated Domains capability plus an apple-app-site-association file on your server. Both arrive in the same place — the .onOpenURL(perform:) modifier, which hands you a URL. You parse that URL into a typed Route and append it to a NavigationStack path binding to push the correct screen. Universal Links are strongly preferred because they open your app directly from a real web link without a scheme prompt.

Entry point
.onOpenURL(perform:)
Web transport
Universal Links + Associated Domains
AASA path
/.well-known/apple-app-site-association
In The Swift Kit
Typed router and deep-link parsing wired into NavigationStack out of the box

Deep linking without the AASA headaches

The Swift Kit ships a typed router and URL parser already wired into NavigationStack, so you configure your domain once and every screen is linkable.

Get The Swift Kit — $99

Set up deep linking end to end

We'll declare a custom scheme for quick testing, add Universal Links for production, define a typed Route, and route incoming URLs into a NavigationStack. All code targets Swift 6 and iOS 16+.

  1. 1

    Declare a custom URL scheme

    Add a CFBundleURLTypes entry in Info.plist so URLs like aurora://profile/42 launch your app. Keep the scheme lowercase and unique to avoid collisions with other apps.

    // Info.plist (source view)
    // <key>CFBundleURLTypes</key>
    // <array>
    //   <dict>
    //     <key>CFBundleURLName</key>
    //     <string>com.aurora.app</string>
    //     <key>CFBundleURLSchemes</key>
    //     <array>
    //       <string>aurora</string>
    //     </array>
    //   </dict>
    // </array>
    
    // Test in the simulator:
    // xcrun simctl openurl booted "aurora://profile/42"
  2. 2

    Define a typed Route

    Model every deep-linkable destination as an enum. This keeps NavigationStack type-safe and gives you one place to parse URLs into.

    enum Route: Hashable {
        case profile(id: Int)
        case settings
        case article(slug: String)
    
        init?(url: URL) {
            guard let host = url.host() ?? url.pathComponents.dropFirst().first else {
                return nil
            }
            let parts = url.pathComponents.filter { $0 != "/" }
            switch host {
            case "profile":
                guard let id = parts.first.flatMap(Int.init) else { return nil }
                self = .profile(id: id)
            case "settings":
                self = .settings
            case "article":
                guard let slug = parts.first else { return nil }
                self = .article(slug: slug)
            default:
                return nil
            }
        }
    }
  3. 3

    Drive navigation from a path binding

    Hold the NavigationStack path in an ObservableObject router so any part of the app can push routes. The .navigationDestination modifier maps each Route case to a screen.

    @MainActor
    final class Router: ObservableObject {
        @Published var path = NavigationPath()
    
        func open(_ url: URL) {
            guard let route = Route(url: url) else { return }
            path.append(route)
        }
    }
    
    struct AppView: View {
        @StateObject private var router = Router()
    
        var body: some View {
            NavigationStack(path: $router.path) {
                HomeView()
                    .navigationDestination(for: Route.self) { route in
                        switch route {
                        case .profile(let id): ProfileView(id: id)
                        case .settings:        SettingsView()
                        case .article(let slug): ArticleView(slug: slug)
                        }
                    }
            }
            .environmentObject(router)
        }
    }
  4. 4

    Catch incoming URLs with .onOpenURL

    Attach .onOpenURL to a view near the root. Both custom-scheme and Universal Link opens funnel through here, so a single handler covers both transports.

    struct AppView: View {
        @StateObject private var router = Router()
    
        var body: some View {
            NavigationStack(path: $router.path) {
                HomeView()
                    .navigationDestination(for: Route.self) { route in
                        destination(for: route)
                    }
            }
            .environmentObject(router)
            .onOpenURL { url in
                router.open(url)
            }
        }
    }
  5. 5

    Add the Associated Domains capability

    For Universal Links, open Signing & Capabilities, add Associated Domains, and register your domain with the applinks: prefix. This is what lets a real https link open your app.

    // Aurora.entitlements
    // <key>com.apple.developer.associated-domains</key>
    // <array>
    //   <string>applinks:aurora.app</string>
    // </array>
    //
    // Add via Xcode > Target > Signing & Capabilities >
    //   + Capability > Associated Domains > applinks:aurora.app
  6. 6

    Host the apple-app-site-association file

    Serve a JSON file at https://yourdomain.com/.well-known/apple-app-site-association with no file extension and Content-Type application/json. It tells iOS which paths map to your app. Apple's CDN caches it, so deploy before testing.

    // https://aurora.app/.well-known/apple-app-site-association
    // {
    //   "applinks": {
    //     "details": [
    //       {
    //         "appIDs": ["ABCDE12345.com.aurora.app"],
    //         "components": [
    //           { "/": "/profile/*" },
    //           { "/": "/article/*" },
    //           { "/": "/settings" }
    //         ]
    //       }
    //     ]
    //   }
    // }

Frequently Asked Questions

Do custom URL schemes and Universal Links both arrive in .onOpenURL in SwiftUI?
Yes. In a pure SwiftUI lifecycle app, both a custom scheme like aurora://profile/42 and a Universal Link like https://aurora.app/profile/42 are delivered to the same .onOpenURL(perform:) closure as a URL. That means you write one parsing path — your Route(url:) initializer — and it handles both transports.
How do I test a deep link in the iOS Simulator without a real device?
Custom schemes work in the simulator: run xcrun simctl openurl booted "aurora://profile/42" from Terminal and the app opens straight to that screen. Universal Links, however, require domain verification that only happens on a real device or via a link tapped in Safari or Notes, so validate those on hardware.
Why does my apple-app-site-association file return a 404 or fail validation?
The file must live at https://yourdomain.com/.well-known/apple-app-site-association with no file extension, be served as application/json over HTTPS, and have zero redirects. A common mistake is uploading apple-app-site-association.json — the extension breaks it. Also confirm the appIDs value is your TeamID.BundleID exactly.
How do I push a deep-linked screen onto an existing NavigationStack path?
Keep a NavigationPath in a Router ObservableObject and bind it to NavigationStack(path:). In your .onOpenURL handler, parse the URL into a Hashable Route and call path.append(route). The matching .navigationDestination(for: Route.self) modifier then renders the target screen, pushing it onto whatever is already on the stack.
Should a deep link reset the navigation stack or push on top of it?
It depends on intent. For a marketing or notification link, replacing the path (router.path = NavigationPath([route])) gives a clean, predictable landing. For an in-app share where the user's context matters, appending preserves their place. Decide per route type — many apps reset for external links and append for internal ones.

Keep exploring

Every screen linkable, day one

The Swift Kit is a $99 one-time SwiftUI boilerplate with deep-link routing, Supabase auth, RevenueCat paywalls, and onboarding already integrated. Lifetime updates, 14-day refund.

Get The Swift Kit — $99

One-time purchase · Lifetime updates · 14-day refund