Universal Link is the iOS world’s “standard” deep linking solution. Apple has been pushing it since 2015. Setup looks simple: drop an apple-app-site-association file, add the entitlement, handle the URL in your app delegate.
Then you ship to production and the surprises start. I’ve wired Universal Links in five apps, and every single one exposed things Apple’s documentation leaves out. Here are four gotchas worth knowing.
1. apple-app-site-association caching
The scenario: you wire up Universal Links, you test, nothing works. You fix the file and test again, still nothing. Delete the app, reinstall, same old behaviour.
The reason: iOS aggressively caches the apple-app-site-association file. And that cache:
– Is device-wide
– Lives for about seven days
– Has invalidation paths that aren’t fully documented
Real-world fixes:
A. Use a query string in the Associated Domains entitlement. applinks:example.com?mode=developer puts you in developer mode where invalidation is more aggressive. Changes propagate fast in TestFlight.
B. Install/reinstall cycle. Uninstall the app, restart the device, reinstall. The cache clears.
C. Use a different domain. Work on a .dev subdomain or a test domain. You don’t want to burn your production domain for a week while waiting on cache expiry.
Understanding the cache behaviour cuts a lot of stress. Plan your dev flow around it.
2. SwiftUI NavigationStack handling is trickier
Before iOS 16, handling application(_:continue:restorationHandler:) in AppDelegate was enough. The SwiftUI App lifecycle changed things:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
handleUniversalLink(url)
}
}
}
}The problem: onOpenURL fires without blowing away app state. The user is on one screen, the link arrives, which screen should they land on? You have to manipulate the NavigationStack path directly.
The fix: an app-wide URLRouter singleton.
@Observable
class URLRouter {
var pendingDeepLink: URL?
var path = NavigationPath()
func handle(_ url: URL) {
// Parse the URL, update the path
if let component = parseURL(url) {
path = NavigationPath([component])
}
}
}Bind the path in ContentView. In onOpenURL, call the router’s handle() function.
3. Safari bypasses Universal Link
Apple’s line is “if a user taps example.com/user/123 in the browser, your app opens”. In practice this falls apart in several places:
- Long-press menu in Safari then “Open”: opens the web, not the app
- QR scanner: sometimes app, sometimes web
- Links in Mail: app opens
- Links in WhatsApp: app opens
- Notion, Telegram, and a few others: they open in an in-app webview and skip the app
Apple’s intention is good, but the experience is inconsistent. Be ready for “it opened in the web” complaints.
Fix: put a smart app banner on every important web page. Apple’s meta tag:
<meta name="apple-itunes-app" content="app-id=123456, app-argument=example.com/deeplink">Safari shows this banner and the user can explicitly jump into the app.
4. Multiple domain handling
Your app might accept links from one domain in production, but:
– Production: example.com
– Staging: staging.example.com
– PR preview: pr-456.example.com
– Test: test.example.com
Every domain needs its own apple-app-site-association plus an Associated Domains entry.
Entitlement:
<array>
<string>applinks:example.com</string>
<string>applinks:staging.example.com</string>
<string>applinks:test.example.com</string>
</array>Question: separate build per domain? Or every domain in every build?
I pick pragmatism: every domain in every build. The production app also handles staging.example.com links, but the staging endpoints only accept authenticated test traffic. The security risk is minimal and the dev flow stays easy.
Subdomain wildcards (iOS 13+):
<string>applinks:*.example.com</string>One wildcard covers every subdomain. Perfect for PR previews.
5. Deferred deep linking
The user taps a link, the app isn’t installed, App Store opens. The user installs, opens the app. The app has to take them to the right screen but the link data is gone.
Universal Link doesn’t solve this out of the box. Facebook’s old Deferred Deep Link patent used to gate this behaviour.
The typical workaround: third-party SDKs like Branch or AppsFlyer. They copy the link to the clipboard and read it back on first launch. iOS 15 prompts for clipboard permission, which breaks the UX.
A better approach: App Clip plus Universal Link. The App Clip URL is also a Universal Link. The user does the quick task in the App Clip, then can install the full app later.
Most of the time this complexity isn’t worth it. The simple path: show a QR on the web page and tell the user to install the app and scan it. Manual but reliable.
Testing
Testing Universal Link is full of traps. The checklist:
- Paste and enter in Safari: does the app open? (type it in the address bar rather than tapping)
- Link from Notes: does the app open? (outside Safari)
- Link inside a notification: URL in the APNs payload
- Messaging apps: WhatsApp, Telegram, Messages
- Email links: Mail.app, Gmail, Outlook
- Camera-scanned QR link
- Background app receives a link: the app is already open
- Cold start: the app is fully closed
Every scenario can behave differently. At minimum hit 1, 2, 3, 8.
Monitoring
In production, measure whether Universal Link is actually succeeding:
- Web-side tracking of “redirected to app” (Apple’s smart banner metrics)
- In-app deep link launch counts (custom analytics event)
- Conversion of deep link arrivals vs organic
These tell you whether the setup is working. Below 5% “opened in app” means something in the user experience is blocking it.
Takeaway
Universal Link setup is simple. Debugging it isn’t. AASA caching, SwiftUI integration, multiple domains, bypass scenarios, none of this is in Apple’s docs, or barely is. You’ll hit all of it in production.
Be patient, build the test matrix, add monitoring. After a week or two you’ll have a working system, and after that you copy-paste the pattern across projects and it stops being painful.