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.
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.
Universal Links vs. custom URL schemes
Both end up in .onOpenURL, but they behave very differently for users. Custom schemes are easy to test but insecure and show a system prompt; Universal Links open your app silently from a real web address and fall back to your website if the app isn't installed. Ship both, but treat Universal Links as the primary path.
- Custom schemes (aurora://) work offline and are perfect for simulator testing via simctl.
- Universal Links (https://aurora.app/...) need the AASA file plus Associated Domains and only verify on device.
- Any app can claim a custom scheme; Universal Links are cryptographically tied to your domain and Team ID.
- If a Universal Link fails to open the app, Safari loads the equivalent web page — design that fallback.
Why isn't my Universal Link opening the app?
This is the most common deep-linking pain. iOS fetches the AASA file once when the app installs and caches aggressively, so mistakes are sticky. Work through the checklist below before assuming your code is wrong.
- The AASA file must be served over HTTPS with Content-Type application/json and no .json extension.
- No redirects allowed on the /.well-known/apple-app-site-association URL.
- The appIDs entry must be TeamID.BundleID exactly.
- Delete and reinstall the app after changing the AASA — iOS caches it.
- Tapping a link that points at the current domain from Safari's address bar won't trigger it; use a link on a page or Notes.
// From Terminal, confirm the file serves correctly:
// curl -I https://aurora.app/.well-known/apple-app-site-association
// Expect: HTTP/2 200 and content-type: application/json
//
// Check Apple's CDN view (may lag your deploy):
// https://app-site-association.cdn-apple.com/a/v1/aurora.appDeep 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.
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
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
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
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
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
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
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?
How do I test a deep link in the iOS Simulator without a real device?
Why does my apple-app-site-association file return a 404 or fail validation?
How do I push a deep-linked screen onto an existing NavigationStack path?
Should a deep link reset the navigation stack or push on top of it?
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 — $99One-time purchase · Lifetime updates · 14-day refund