Deep links and navigation
Contents
- Intent
- Core patterns
- Example: router entry points
- Example: attach to a root view
- Design choices to keep
- Pitfalls
- Universal Links
- Custom URL Schemes
- NSUserActivity Continuation (Handoff)
Intent
Route external URLs into in-app destinations while falling back to system handling when needed.
Core patterns
- Centralize URL handling in the router (
handle(url:),handleDeepLink(url:)). - Inject an
OpenURLActionhandler that delegates to the router. - Use
.onOpenURLfor Universal Links and custom URL schemes. - Use
.onContinueUserActivityfor Handoff and other declared user activity types. - Let the router decide whether to navigate or open externally.
Example: router entry points
@MainActor
final class RouterPath {
var path: [Route] = []
var urlHandler: ((URL) -> OpenURLAction.Result)?
func handle(url: URL) -> OpenURLAction.Result {
guard let route = parseInternal(url), isAuthorized(route), destinationExists(route) else {
return urlHandler?(url) ?? .systemAction
}
path.append(route) // Commit only after every validation passes.
return .handled
}
func handleDeepLink(url: URL) -> OpenURLAction.Result {
guard let route = parseInternal(url), isAuthorized(route), destinationExists(route) else {
return .discarded
}
path.append(route)
return .handled
}
}Example: attach to a root view
extension View {
func withLinkRouter(_ router: RouterPath) -> some View {
self
.environment(
\.openURL,
OpenURLAction { url in
router.handle(url: url)
}
)
.onOpenURL { url in
router.handleDeepLink(url: url)
}
}
}Design choices to keep
- Keep URL parsing and decision logic inside the router.
- Avoid handling deep links in multiple places; one entry point is enough.
- Always provide a fallback to
@Environment(\.openURL)viaOpenURLAction.
Pitfalls
- Parse and validate before mutating any tab or navigation path. Invalid links must leave current navigation unchanged.
- Avoid blocking UI while resolving remote links; use
Task.
Universal Links
Universal links let iOS open your app when a user taps a standard HTTPS URL, with no custom scheme required. They require server-side configuration and an Associated Domains entitlement.
Apple App Site Association (AASA)
Host a JSON file at https://example.com/.well-known/apple-app-site-association (no file extension, served with Content-Type: application/json):
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.example.app"],
"components": [
{ "/": "/items/*", "comment": "Match item detail paths" },
{ "/": "/profile/*" }
]
}
]
}
}Key rules:
- AASA must be served over HTTPS with a valid certificate; do not redirect the AASA request.
- On iOS 14+, Apple's CDN retrieves and caches AASA files. Devices download the file on install and normally check again about once per week; there is no direct CDN invalidation. Reinstall the app or use developer mode while testing changes.
- Use
components(modern) over the legacypathsarray.
Associated Domains entitlement
In your app's .entitlements file (or Signing & Capabilities in Xcode), add:
com.apple.developer.associated-domains = [
"applinks:example.com",
"applinks:www.example.com"
]For development/testing, prefix with applinks:example.com?mode=developer to bypass CDN-backed retrieval.
Handling Universal Links in SwiftUI
SwiftUI receives Universal Links directly as URLs. Handle them with .onOpenURL:
@main
struct MyApp: App {
@State private var router = Router()
var body: some Scene {
WindowGroup {
ContentView()
.environment(router)
.onOpenURL { url in
router.handle(url: url)
}
}
}
}Custom URL Schemes
Custom URL schemes (e.g., myapp://) let other apps or websites open your app. They do not require server configuration but offer no fallback if the app is not installed.
Registering in Info.plist
Add CFBundleURLTypes to your target's Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
<key>CFBundleURLName</key>
<string>com.example.myapp</string>
</dict>
</array>Handling with .onOpenURL
.onOpenURL { url in
// url.scheme == "myapp"
// url.host == "items", url.pathComponents for routing
guard url.scheme == "myapp" else { return }
router.handle(url: url)
}Prefer universal links over custom schemes for publicly shared links — they provide a better UX (web fallback) and are more secure (domain-verified).
NSUserActivity Continuation (Handoff)
Handoff lets users start an activity on one device and continue it on another. SwiftUI provides .onContinueUserActivity and .userActivity modifiers.
Advertising an activity
struct ItemDetailView: View {
let item: Item
var body: some View {
ScrollView { /* content */ }
.userActivity("com.example.viewItem") { activity in
activity.title = item.title
activity.isEligibleForHandoff = true
activity.isEligibleForSearch = true
activity.targetContentIdentifier = item.id.uuidString
activity.webpageURL = URL(string: "https://example.com/items/\(item.id)")
}
}
}Receiving a continued activity
.onContinueUserActivity("com.example.viewItem") { activity in
guard let id = activity.targetContentIdentifier else { return }
router.navigate(to: .item(id: id))
}Key rules:
- Activity types must be declared in
Info.plistunderNSUserActivityTypes. - Set
isEligibleForHandoff = trueand optionallyisEligibleForSearch/isEligibleForPrediction. - Provide a
webpageURLas fallback when the app is not installed on the receiving device. - Do not use the browsing-web user activity hook as the primary SwiftUI Universal Link handler; use
.onOpenURLfor Universal Links.