DEV Community

LinkTrail
LinkTrail

Posted on

Deferred Deep Links in iOS: Complete Integration Guide

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 NSUserActivity of type NSUserActivityTypeBrowsingWeb.
  • 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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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": [{ "/": "/*" }]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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 userActivity fires.
  • Deferred: delete the app completely, tap the link, go through the App Store, install, open cold. Confirm getFirstOpenLink resolves — 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.com to www.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.