On this page
scope
A complete walkthrough for setting up iOS Universal Links from zero. Covers the AASA file, the Xcode Associated Domains entitlement, the AppDelegate / SceneDelegate handlers, and the most common debugging steps when Universal Links route to Safari instead of opening your app.
Cluster C hub: /guides/firebase-dynamic-links-replacement. Pair: /guides/android-app-links-setup.
prerequisites
- An iOS app project in Xcode with a valid signing identity and provisioning profile.
- An Apple Developer Team ID (10 character alphanumeric, visible at developer.apple.com → Membership).
- A domain you control with the ability to serve a static file over HTTPS and a valid TLS certificate.
- App Store Connect access if you intend to distribute the app via TestFlight or the App Store.
You will not need the Apple Push Notification entitlement, App Groups, or any other capability. Universal Links require only Associated Domains.
step 1 — choose the domain
The domain that will host your Universal Links must:
- Be HTTPS with a publicly trusted certificate (no self-signed, no Let's Encrypt staging).
- Serve
/.well-known/apple-app-site-associationdirectly, with no redirects, and withContent-Type: application/json. - Be reachable from Apple's CDN proxy (no IP allowlists, no Cloudflare "Under Attack" mode).
Apex (yourdomain.com) and www.yourdomain.com are both valid. Use one consistently. Subdomains work but require the entitlement to list each separately.
step 2 — publish the AASA file
Create the file at https://yourdomain.com/.well-known/apple-app-site-association. Format details: /guides/aasa-file-explained.
Minimal example:
{
"applinks": {
"details": [
{
"appIDs": ["ABCD1234EF.com.yourcompany.yourapp"],
"components": [
{
"/": "/product/*",
"comment": "Product pages route to the app"
},
{
"/": "/profile/*"
}
]
}
]
}
}
Notes:
appIDsis the Team ID +.+ bundle ID. Multiple app IDs can be listed for shared-domain scenarios.- Each
componentsentry's/field is a glob pattern.*matches any path component;?matches a single character. - The file must NOT have a
.jsonextension on the URL. The OS fetches the path as-is. - The file must be served as
Content-Type: application/json; charset=utf-8. Some hosting platforms default totext/plainfor extensionless files; override it. - No redirects. iOS rejects AASA files served via 301 or 302.
Verify:
curl -sI https://yourdomain.com/.well-known/apple-app-site-association | head
# Expected:
# HTTP/2 200
# content-type: application/json; charset=utf-8
# content-length: 234
curl -s https://yourdomain.com/.well-known/apple-app-site-association | python3 -m json.tool
# Expected: valid JSON, structurally correct
step 3 — add the entitlement in Xcode
Open the project in Xcode → select the target → Signing & Capabilities → + Capability → search "Associated Domains" → add it.
In the Associated Domains list, add an entry:
applinks:yourdomain.com
If you have multiple domains (apex + subdomain, or staging + production), add each:
applinks:yourdomain.com
applinks:staging.yourdomain.com
For development builds, you can append ?mode=developer to bypass Apple's CDN cache and force fresh AASA fetches per build:
applinks:yourdomain.com?mode=developer
Use this during initial setup, remove it before App Store submission.
The entitlement adds a com.apple.developer.associated-domains key to your app's entitlements file. Confirm it's present:
codesign -d --entitlements - YourApp.app/
step 4 — implement the handler
iOS routes Universal Link taps via NSUserActivity to AppDelegate (for UIKit lifecycle) or SceneDelegate (for the newer scene-based lifecycle).
UIKit lifecycle (AppDelegate.swift)
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL,
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
components.host == "yourdomain.com" else {
return false
}
let path = components.path
if path.hasPrefix("/product/") {
let id = path.replacingOccurrences(of: "/product/", with: "")
AppRouter.shared.route(to: .product(id: id))
return true
}
return false
}
Scene-based lifecycle (SceneDelegate.swift)
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
if let userActivity = connectionOptions.userActivities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb }) {
handle(userActivity: userActivity)
}
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
handle(userActivity: userActivity)
}
private func handle(userActivity: NSUserActivity) {
guard let url = userActivity.webpageURL else { return }
AppRouter.shared.route(from: url)
}
The continue callback fires for warm app launches; the connectionOptions path covers cold launches where the user tapped a Universal Link as the entry point.
step 5 — verify on device
Universal Links can only be tested on a real device, not a simulator.
- Build and run on a physical iPhone or iPad.
- After install, send yourself an iMessage or Mail containing the test URL:
https://yourdomain.com/product/123. - Tap the link. The app should open.
If it opens Safari instead:
- Check that the AASA file is reachable from Apple's CDN. Use Apple's
swcutiltool on a Mac connected to the test device via USB:sudo /usr/bin/swcutil show -d yourdomain.com - Force a re-fetch of the AASA by deleting the app and reinstalling. iOS caches the file aggressively.
- Check that the
applinks:entry in the entitlement matches the host exactly. No trailing slashes, nohttps://prefix. - Long-press the link in Messages. If the popup shows "Open in [Your App]", the association is working but the user previously chose Safari. Reset via Settings → Apps → Your App → Universal Links.
step 6 — common failure modes
| Symptom | Likely cause |
|---|---|
| Link always opens Safari | AASA not reachable, or Content-Type wrong, or path pattern doesn't match |
| Link works on cold launch but not warm relaunch | Missing continue userActivity handler in SceneDelegate |
| Link works for some paths but not others | Path glob in AASA components doesn't match the URL pattern |
| Link works on developer device but not TestFlight | Used ?mode=developer and forgot to remove for distribution build |
| Link works for app installed via Xcode but not App Store | AASA cached with old data — Apple CDN refreshed; test devices may need reinstall |
| Link tapped inside Instagram or TikTok opens web instead of app | Expected — Universal Links don't trigger from inside WKWebView. See /guides/in-app-browser-logged-out |
The in-app browser case is platform behavior, not a setup bug. The host webview consumes the navigation before iOS can dispatch it.
step 7 — AASA cache invalidation
After a TestFlight or App Store update that changes paths, Apple's CDN may cache the AASA for up to 24 hours. To force a refresh:
- Increment your
Info.plistCFBundleShortVersionString. - Use the
?mode=developerentitlement option during development. - On user devices, full app reinstall always picks up fresh AASA.
In production, you cannot force the user's device to refetch. Plan AASA changes to be additive — new path patterns work without breaking old ones.
step 8 — production hardening
Before App Store submission:
- Remove
?mode=developerfrom the entitlement. - Verify AASA is reachable from a fresh device with no prior cache.
- Add monitoring on the AASA URL — alert if it 404s or stops returning
application/json. - Consider serving the AASA file via your app's main hostname's CDN, not a separate static-asset domain. The associated-domain string must exactly match the URL host.
related dev reading
- Pair (Android): /guides/android-app-links-setup
- AASA file format: /guides/aasa-file-explained
- assetlinks.json format: /guides/assetlinks-json-explained
- Universal vs deep vs app link: /guides/universal-link-vs-deep-link-vs-app-link
- Open-in-app pattern: /guides/open-in-app-from-web
- Cluster C hub: /guides/firebase-dynamic-links-replacement
- Bridge: /guides/in-app-browser-logged-out
For non-app linking infrastructure where Universal Links don't apply (web-only flows, bio links, campaign URLs), the no-SDK linkboo path is at link.boo/api.
references
- Apple Supporting Universal Links documentation
- Apple App Site Association format specification
- Apple
swcutildebugging tool documentation - Apple Associated Domains entitlement reference