Universal Links solve exactly one case: a user who already has your app taps a link and iOS hands it straight to scene(_:continue:). They do nothing for the user who doesn't have the app yet — taps the link, gets sent to the App Store, installs, opens — and lands on a plain launch screen, because there's no OS mechanism to carry anything across an install on iOS. This is the native Swift integration guide for both cases with LinkTrail.
The two cases
-
Direct link — app is already installed. iOS routes the tap to your app via Universal Links, delivered as an
NSUserActivityof typeNSUserActivityTypeBrowsingWeb. - Deferred link — app is not installed. Nothing is delivered by the OS on first launch — no Universal Link, no custom scheme, nothing. The match has to happen server-side and get pulled down on first open, which is a fundamentally different mechanism from Universal Links, not an extension of them.
1. Add the SDK
CocoaPods:
# Podfile
pod 'LinkTrail', '~> 1.3'
Or Swift Package Manager — add https://github.com/linktrail/linktrail-ios-sdk in Xcode's package manager.
2. Enable Associated Domains
In Xcode, under Signing & Capabilities, add the Associated Domains capability:
applinks:links.yourapp.com
Serve the AASA file at https://links.yourapp.com/.well-known/apple-app-site-association — no extension, Content-Type: application/json, and critically: no redirects. Apple's crawler doesn't follow them.
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.yourcompany.yourapp"],
"components": [{ "/": "/*" }]
}
]
}
}
TEAMID is your Apple Developer Team ID, not your bundle ID alone — this is the single most common typo in an AASA file, and it fails silently: the file loads fine, it just never matches.
3. Initialize the SDK
In AppDelegate (or your App struct's init if you're on pure SwiftUI lifecycle):
import LinkTrail
@mainclass AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
LinkTrail.configure(apiKey: "YOUR_LINKTRAIL_API_KEY")
return true
}
}
4. Handle the direct case
Universal Links arrive through continue userActivity, either in AppDelegate or your SceneDelegate, depending on your app's lifecycle:
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else {
return false
}
LinkTrail.resolve(url) { link in
self.routeFromLink(link)
}
return true
}
If you're using the UIScene lifecycle instead:
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
LinkTrail.resolve(url) { link in
self.routeFromLink(link)
}
}
Only implement one of these — whichever matches your actual app lifecycle. Implementing both and expecting either to silently no-op is a common source of double-handling bugs.
5. Handle the deferred case
Call this once, on first launch — it's safe to call on every launch, since it resolves to nil on every run after the first:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
LinkTrail.configure(apiKey: "YOUR_LINKTRAIL_API_KEY")
LinkTrail.getFirstOpenLink { link in
guard let link = link else { return }
self.routeFromLink(link)
}
return true
}
Under the hood this is a probabilistic match against signals captured when the link was tapped in the browser — there's no cookie or identifier surviving the App Store install, so it's inherently a best-effort resolution, not a guarantee. That's worth knowing going in: test it, but don't expect 100% match rates in every environment (the simulator in particular is not representative).
6. Route on the resolved link
func routeFromLink(_ link: LinkTrailLink) {
switch link.path {
case "/product":
let id = link.params["id"]
coordinator.navigate(to: .productDetail(id: id))
case "/invite":
let ref = link.params["ref"]
coordinator.navigate(to: .onboarding(referrer: ref))
default:
coordinator.navigate(to: .home)
}
}
7. Test it for real
-
Direct: send yourself the link over Messages or Notes (Safari's own address bar won't trigger a Universal Link — it treats it as browsing, not an app hand-off). Tap it with the app installed, confirm
continue userActivityfires. -
Deferred: delete the app completely, tap the link, go through the App Store, install, open cold. Confirm
getFirstOpenLinkresolves — and don't test this on the same device/network too many times in a row; iOS and ad networks alike will start deprioritizing signals that look automated. -
Validate the AASA file independently of your app — a 404, an HTML error page instead of JSON, or a redirect from
yourapp.comtowww.yourapp.com(Apple treats those as different hosts and doesn't follow the hop) are the most common reasons Universal Links silently fail, and none of them show up as a crash or an error in your app — they just don't route.
What's next
This gets both cases routed with real link data in hand. What it doesn't cover is attribution — matching an install back to the campaign or creative that drove it, layered on top of this same resolution — which deserves its own post.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.