How do universal links work, and how are they different from custom URL schemes?
TL;DR
A universal link is a normal https:// URL. If the app is installed, iOS opens the app. If not, the link opens in Safari.
It works because your website hosts an apple-app-site-association (AASA) file, and your app has the Associated Domains entitlement. Both sides prove they belong together.
A custom URL scheme (myapp://) is not verified. Any app can claim the same scheme.
Setup in three parts
1. The AASA file on your server
Host it at https://example.com/.well-known/apple-app-site-association. It is JSON with no file extension, served over HTTPS with no redirects.
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.example.shop"],
"components": [
{ "/": "/product/*", "comment": "Product pages" },
{ "/": "/account/*", "exclude": true }
]
}
]
}
}ABCDE12345 is your Team ID. The components decide which paths open the app.
2. The entitlement in the app
In Signing & Capabilities, add Associated Domains:
applinks:example.com
applinks:www.example.com3. Handle the link in code
SwiftUI:
WindowGroup {
RootView()
.onOpenURL { url in
router.handle(url)
}
}UIKit scene delegate:
// App already running
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
router.handle(url)
}
// Cold launch: the link is in connectionOptions
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options: UIScene.ConnectionOptions) {
if let url = options.userActivities.first?.webpageURL {
router.handle(url)
}
}Forgetting the cold launch case is a very common bug.
Parsing the URL
func handle(_ url: URL) {
let parts = url.pathComponents // ["/", "product", "123"]
if parts.count == 3, parts[1] == "product" {
path = [.product(id: parts[2])]
}
}How iOS checks the file
When the app is installed or updated, iOS downloads the AASA file through Apple's CDN, not directly from your server. So:
- Changes can take time (hours to days) to reach devices.
- For testing, use
applinks:example.com?mode=developerand turn on Associated Domains Development in Developer settings on the device. Then the device fetches the file directly.
Universal links vs URL schemes
| Universal links | Custom URL scheme | |
|---|---|---|
| Format | https://example.com/product/1 | myapp://product/1 |
| Verified ownership | Yes (AASA + entitlement) | No |
| App not installed | Opens the website | Error or nothing |
| Other apps can hijack it | No | Yes |
| Good for | Marketing links, emails, sharing | App-to-app calls, OAuth callbacks (use ASWebAuthenticationSession) |
When universal links do not open the app
- The user typed the link into Safari's address bar. That stays in Safari.
- The link is on the same domain that is already open in Safari.
- The user long-pressed and chose "Open in Safari". iOS remembers this choice.
- The AASA file is wrong, redirected, or not served with HTTPS.
- Some apps open links in their own in-app browser.
Security note
Treat every incoming URL as untrusted input. Validate IDs and parameters. Never perform actions like payments or deleting data just because a link was opened. Ask the user to confirm.
Common follow-up questions
- What is the difference with App Links on Android? Same idea. Android uses
assetlinks.json. - How do you support links when the app is not installed and then gets installed? That is "deferred deep linking". It needs a third-party service or your own server logic. iOS does not provide it.
- Can one link open different apps? Yes, list several
appIDs. iOS picks an installed one. - What about App Clips? They use
appclips:in Associated Domains and the same AASA file.