SwiftUI handles Universal Links differently from UIKit. There is no AppDelegate method to override, no scene delegate callback to wire up. Instead, SwiftUI gives you a modifier called .onOpenURL that you can attach directly to views. The approach is cleaner and more composable, but it requires understanding where to place the modifier and how to route URLs to the right part of your navigation hierarchy.
This guide walks through every practical aspect of Universal Links in SwiftUI: basic setup with .onOpenURL, building a typed deep link router, integrating with NavigationStack, handling links at the app level versus the view level, and testing without a device. It also covers how to handle hybrid apps that mix SwiftUI with UIKit.
Before diving into the SwiftUI-specific code, make sure your apple-app-site-association file is hosted correctly and your Xcode project has the Associated Domains entitlement configured. Our AASA file setup guide covers the server-side requirements, and the Tolinku Universal Links documentation has the full technical reference.
The .onOpenURL Modifier
The primary entry point for Universal Links in SwiftUI is the .onOpenURL modifier. It accepts a closure that receives a URL whenever the system routes a link to your app.
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
print("Received URL: \(url)")
}
}
}
}
This is the simplest possible implementation. The closure fires when the app is launched via a Universal Link, when a Universal Link is tapped while the app is already running in the foreground, and when the app is brought to the foreground from the background via a link.
One thing to know: .onOpenURL does not fire when the app is completely terminated and a link opens it. In that case, iOS launches the app normally and delivers the URL through the onOpenURL modifier on the first frame. In practice this means you do not need any special "cold start" handling. The modifier covers all three states.
Where to place it. You can attach .onOpenURL to any view, but placing it high in the hierarchy (on ContentView or on the WindowGroup itself) gives it the widest scope. If you attach it to a deeply nested view, it only fires when that view is part of the current view hierarchy. For most apps, placing .onOpenURL on the root view inside WindowGroup is the right call.
Building a Deep Link Router
Passing raw URL objects around is messy. A better pattern is to parse incoming URLs into a typed enum that describes the destination, then react to that enum in your views.
enum DeepLink: Equatable {
case home
case profile(userID: String)
case product(productID: String)
case order(orderID: String)
case unknown(url: URL)
static func from(_ url: URL) -> DeepLink {
guard url.host == "myapp.com" else { return .unknown(url: url) }
let pathComponents = url.pathComponents.filter { $0 != "/" }
switch pathComponents.first {
case "profile":
let userID = pathComponents.dropFirst().first ?? ""
return .profile(userID: userID)
case "product":
let productID = pathComponents.dropFirst().first ?? ""
return .product(productID: productID)
case "order":
let orderID = pathComponents.dropFirst().first ?? ""
return .order(orderID: orderID)
default:
return .home
}
}
}
With the enum in place, create an ObservableObject to hold the current deep link state:
@MainActor
final class DeepLinkRouter: ObservableObject {
@Published var pendingLink: DeepLink?
func handle(_ url: URL) {
pendingLink = DeepLink.from(url)
}
func consume() {
pendingLink = nil
}
}
Inject this router into your view hierarchy using @StateObject at the app level, then pass it down through the environment:
@main
struct MyApp: App {
@StateObject private var router = DeepLinkRouter()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(router)
.onOpenURL { url in
router.handle(url)
}
}
}
}
Any view that needs to respond to incoming links can pull the router from the environment:
struct ContentView: View {
@EnvironmentObject var router: DeepLinkRouter
var body: some View {
TabView {
HomeTab()
ProfileTab()
OrdersTab()
}
.onChange(of: router.pendingLink) { _, link in
guard let link else { return }
handleLink(link)
}
}
private func handleLink(_ link: DeepLink) {
// Navigate based on the link type
router.consume()
}
}
NavigationStack Integration
NavigationStack in iOS 16 and later uses a path-based navigation model that works well with deep linking. Instead of pushing views imperatively, you maintain a navigation path and append destinations to it.
enum AppDestination: Hashable {
case profile(userID: String)
case product(productID: String)
case order(orderID: String)
}
struct HomeTab: View {
@EnvironmentObject var router: DeepLinkRouter
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
HomeView()
.navigationDestination(for: AppDestination.self) { destination in
switch destination {
case .profile(let userID):
ProfileView(userID: userID)
case .product(let productID):
ProductDetailView(productID: productID)
case .order(let orderID):
OrderDetailView(orderID: orderID)
}
}
}
.onChange(of: router.pendingLink) { _, link in
guard let link else { return }
navigateToLink(link)
}
}
private func navigateToLink(_ link: DeepLink) {
switch link {
case .profile(let userID):
navigationPath.append(AppDestination.profile(userID: userID))
case .product(let productID):
navigationPath.append(AppDestination.product(productID: productID))
case .order(let orderID):
navigationPath.append(AppDestination.order(orderID: orderID))
case .home, .unknown:
navigationPath = NavigationPath()
}
router.consume()
}
}
The key advantage here is that you can navigate to any depth in the stack from a single incoming URL. You can also reset the stack to the root before navigating, which is useful for notifications and links that should replace the current context rather than push on top of it.
To reset to root and then navigate, set navigationPath to an empty NavigationPath() before appending the destination. SwiftUI will animate the transition correctly.
Handling Links at the App Struct vs. View Level
You have a choice of where to put .onOpenURL: on the WindowGroup directly, or on a view inside it.
App struct level (on WindowGroup or its direct child):
WindowGroup {
ContentView()
.onOpenURL { url in
router.handle(url)
}
}
This fires regardless of which view is currently visible. It is the right place for global link handling.
View level (on a specific view):
struct ProfileView: View {
var body: some View {
Text("Profile")
.onOpenURL { url in
// Only fires when ProfileView is in the hierarchy
}
}
}
View-level placement is useful when a specific view should intercept certain links and handle them locally, without routing through a global router. The catch is that the modifier only fires while that view is part of the active view hierarchy. If the user navigates away from ProfileView, links will not reach it.
For most Universal Links implementations, global handling through the app struct is the cleaner approach. Local view-level handling works for edge cases, like a settings view that handles account linking callbacks.
Environment-Based Navigation
For tab-based apps, you often need a deep link to both switch to the correct tab and push a view on that tab's navigation stack. The cleanest approach is to hold both the selected tab and the per-tab navigation paths in a single router object.
enum AppTab: Int, Hashable {
case home, profile, orders
}
@MainActor
final class AppRouter: ObservableObject {
@Published var selectedTab: AppTab = .home
@Published var homeNavPath = NavigationPath()
@Published var profileNavPath = NavigationPath()
@Published var ordersNavPath = NavigationPath()
func handle(_ url: URL) {
let link = DeepLink.from(url)
switch link {
case .home:
selectedTab = .home
homeNavPath = NavigationPath()
case .profile(let userID):
selectedTab = .profile
profileNavPath = NavigationPath()
profileNavPath.append(AppDestination.profile(userID: userID))
case .product(let productID):
selectedTab = .home
homeNavPath = NavigationPath()
homeNavPath.append(AppDestination.product(productID: productID))
case .order(let orderID):
selectedTab = .orders
ordersNavPath = NavigationPath()
ordersNavPath.append(AppDestination.order(orderID: orderID))
case .unknown:
break
}
}
}
Your TabView binds to router.selectedTab, and each tab's NavigationStack binds to the corresponding path property. A single incoming URL can now switch tabs and navigate deep into a specific tab in one operation.
Testing Deep Links in Xcode
You do not need a physical device to test Universal Link routing during development. Xcode provides two ways to simulate incoming links.
Xcode scheme launch URL. In your scheme settings (Product > Scheme > Edit Scheme), select "Run" on the left, then the "Arguments" tab. Under "Environment Variables", add no special keys. Instead, look at the "Options" tab and find the "URL" field under "Core Location." Actually, the direct approach is to add a launch argument or use the simulated location. The cleaner method is the xcrun simctl openurl command:
xcrun simctl openurl booted "https://myapp.com/product/abc123"
This sends the URL to the booted simulator as if the user tapped a Universal Link. Your .onOpenURL modifier fires and you can step through the routing logic in the debugger. For a comprehensive list of testing approaches (including device testing, Charles Proxy, and Apple's diagnostic tools), see our guide on testing Universal Links.
Xcode Previews. You cannot trigger .onOpenURL directly from a SwiftUI preview because previews do not participate in URL routing. However, you can preview the navigation results by injecting a pre-configured router state:
#Preview {
ContentView()
.environmentObject({
let router = AppRouter()
router.selectedTab = .profile
router.profileNavPath.append(AppDestination.profile(userID: "user_123"))
return router
}())
}
This lets you visually verify that a given deep link lands on the correct screen with the correct data, without needing to trigger the URL routing machinery.
Combining SwiftUI with UIKit (Hybrid Apps)
If your app uses a hybrid UIKit/SwiftUI approach (common in apps that were built before SwiftUI matured), you need to bridge Universal Link handling between the two frameworks.
In a UIKit-rooted app where SwiftUI views are presented via UIHostingController, Universal Links still arrive through the UIApplicationDelegate and UISceneDelegate callbacks:
// UISceneDelegate
func scene(
_ scene: UIScene,
continue userActivity: NSUserActivity
) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
// Forward to your SwiftUI router
AppRouter.shared.handle(url)
}
If you are using @main with the SwiftUI App protocol, you can still hook into scene activity via the onContinueUserActivity modifier:
@main
struct MyApp: App {
@StateObject private var router = AppRouter()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(router)
.onOpenURL { url in
router.handle(url)
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
guard let url = activity.webpageURL else { return }
router.handle(url)
}
}
}
}
In most cases .onOpenURL covers everything you need. The onContinueUserActivity modifier is more relevant for Handoff and Spotlight search result handling, but adding it costs nothing and ensures you capture any URL that arrives through either path.
Query Parameters and Deferred Data
Universal Links often carry query parameters for attribution, referral codes, or pre-filled state. Parse these from the URL components:
extension DeepLink {
static func from(_ url: URL) -> DeepLink {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return .unknown(url: url)
}
let pathComponents = components.path
.split(separator: "/")
.map(String.init)
let queryItems = components.queryItems ?? []
let referralCode = queryItems.first(where: { $0.name == "ref" })?.value
switch pathComponents.first {
case "product":
let productID = pathComponents.dropFirst().first ?? ""
return .product(productID: productID)
// Additional cases...
default:
return .home
}
}
}
Attribution data embedded in Universal Links is how platforms like Tolinku track which campaigns drive installs and which links convert users after the app opens. The iOS SDK handles the attribution side automatically, but your routing code still needs to read and pass through these parameters to the destination screen.
Common Patterns and Pitfalls
Multiple windows on iPadOS. If your app supports multiple windows on iPadOS, each WindowGroup instance has its own .onOpenURL modifier. The system routes the URL to whichever window is key. If you hold router state in @StateObject inside the App struct, you need to ensure the state is shared across windows, not duplicated per window. Using a singleton or passing state via the environment from a shared source resolves this.
URL normalization. Always normalize incoming URLs before parsing. Lowercase the host, strip trailing slashes from the path, and handle percent-encoding. A URL that looks like /Product/ABC123 should route to the same destination as /product/abc123.
Fallback handling. Your router's unknown case should have a clear behavior, either navigate to the home screen or show a "link not recognized" message. Silently dropping unrecognized links confuses users and makes debugging harder.
Navigation race conditions. When the app launches cold from a Universal Link, the view hierarchy might not be fully rendered by the time .onOpenURL fires. If you observe that navigation does not occur on cold launch, delay the navigation by one run loop cycle using Task { @MainActor in ... } or DispatchQueue.main.async.
For more on integrating Universal Links with the Tolinku iOS SDK, including attribution callbacks and deferred deep linking after install, the SDK documentation has the full setup guide.
Summary
SwiftUI's .onOpenURL modifier gives you a clean, declarative way to handle Universal Links without the ceremony of UIKit delegate callbacks. The core pattern is: attach .onOpenURL high in the view hierarchy, parse the URL into a typed enum, store the pending link in an ObservableObject, and react to changes with .onChange. From there, NavigationStack with path-based navigation makes it straightforward to land the user on the correct screen regardless of where they are in the app.
If you are new to Universal Links or want a broader overview before implementing the SwiftUI patterns above, our complete guide to Universal Links covers the end-to-end setup.
The typed enum approach pays dividends as your app grows. Adding a new deep link destination means adding a case to the enum and a branch in the router. The rest of the app stays unchanged.
Get deep linking tips in your inbox
One email per week. No spam.