How to Build Universal Links in SwiftUI
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
-
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. -
Apple App Site Association file — Host a JSON file at
https://www.yourdomain.com/.well-known/apple-app-site-associationwith 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. -
.onOpenURLon theWindowGroup— 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 theWindowGroup(not a child view) ensures it is always active. -
AppRouter.handle(_:)— Parses the URL's path segments, validates the host (prevents spoofing from unrelated URLs), and appends the appropriateAppDestinationvalue toNavigationPath. BecauseAppRouteris@Observable, SwiftUI automatically re-rendersNavigationStackwhen the path changes. -
navigationDestination(for:)— Declared once on the rootNavigationStack, it maps everyAppDestinationcase 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
-
AASA cached aggressively. iOS (and Apple's CDN) caches the
apple-app-site-associationfile for up to 24 hours. After updating the file on your server, testers must delete and reinstall the app, or use Associated Domains Development mode (add?mode=developerto the domain in entitlements) to bypass the cache during development. -
.onOpenURLfires beforeonAppear. If you read router state insideonAppearon ContentView expecting it to reflect a cold-launch deep link, you will race. Instead, letNavigationStackreactively render fromrouter.path— noonAppearcoordination needed. -
Non-unique
NavigationPathvalues cause silent drops. If two different product URLs produce the sameAppDestinationvalue (e.g. both map to.product(id: "")),NavigationStackmay not push a new screen because the path value is unchanged. Always include a meaningful, unique identifier in every destination case. -
Accessibility labels on destination views. VoiceOver announces the
navigation title automatically, but content inside destination views should carry explicit
.accessibilityLabelmodifiers on dynamic text (product IDs, order numbers) so they read naturally rather than as raw UUID strings.
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.