Debug Universal Links In SwiftUI
Universal Links are one of those features that feel magical when they work and deeply suspicious when they do not. The app builds. The website loads. The link looks right. Then iOS opens Safari anyway and gives you absolutely no dramatic error message to stare at.
The trick is to debug Universal Links like a contract. Your app says, “I can handle links for this domain.” Your website says, “Yes, this exact app can handle these exact URL patterns.” iOS checks both sides, caches the result, and only then delivers the URL into your SwiftUI app.
Here is the checklist I use when that chain breaks.
Check the entitlement first
In Xcode, select your app target, open Signing & Capabilities, and add Associated Domains. For Universal Links, each entry should use the applinks service:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
<string>applinks:www.example.com</string>
</array>
Do not include https://, a path, query items, or a trailing slash. The entitlement wants a fully qualified domain, not a URL.
Also be literal about hosts. example.com, www.example.com, and links.example.com are different hosts. If your production links use www.example.com, but your entitlement only has applinks:example.com, your app and website are not talking about the same place.
If you use manual signing, make sure the App ID and provisioning profile include the Associated Domains capability too. Xcode usually handles this for you, but stale profiles can make a correct-looking .entitlements file lie to your actual build.
Host the AASA file directly
Your website needs an apple-app-site-association file, often called the AASA file. Use this URL:
https://example.com/.well-known/apple-app-site-association
The filename has no .json extension. It must be served over HTTPS with a valid certificate. When debugging, I want this endpoint to return:
200 OK- JSON body
Content-Type: application/json- no
301,302, or other redirect - no HTML error page from a CDN, firewall, auth layer, or hosting fallback
Apple’s older Universal Links guide allowed the root location too, but current setup guidance points at /.well-known/apple-app-site-association. I would put it there and avoid clever redirects. Redirects are especially important: the AASA file itself needs to be available directly. If https://example.com/.well-known/apple-app-site-association redirects somewhere else, fix the server instead of hoping iOS follows it.
Here is a small modern AASA file:
{
"applinks": {
"details": [
{
"appIDs": [
"ABCDE12345.com.example.MyApp"
],
"components": [
{
"/": "/articles/*",
"comment": "Open article URLs in the app."
},
{
"/": "/account/delete",
"exclude": true,
"comment": "Keep destructive account deletion on the web."
},
{
"/": "/products/*",
"?": {
"ref": "*"
},
"comment": "Open product links that include a ref query item."
}
]
}
]
}
}
The appIDs value is your application identifier prefix, usually your Team ID, followed by your bundle identifier. If you are unsure, check the built app’s entitlements or your Apple Developer account instead of guessing.
You may also see the older format:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "ABCDE12345.com.example.MyApp",
"paths": [
"/articles/*",
"NOT /account/delete"
]
}
]
}
}
That format still appears in plenty of apps and old answers. The annoying part is mixing formats. Use components with appID or appIDs, or use the legacy paths array with the legacy shape. Do not combine components and paths in the same app detail and expect predictable results.
Verify the server response
Before opening Xcode again, ask the server what it is actually returning:
curl -i https://example.com/.well-known/apple-app-site-association
If the response starts with a redirect, an HTML document, a login page, or a 403 from a bot filter, you found the bug. The device has to fetch this file without cookies, without your browser session, and without special treatment for your office IP address.
I also like testing with a boring user agent:
curl -i -A "UniversalLinkDebug/1.0" \
https://example.com/.well-known/apple-app-site-association
If that returns something different, your CDN or firewall is part of the problem.
On a Mac, Apple’s swcutil can validate the JSON and a URL pattern:
sudo swcutil verify \
-d example.com \
-j ./apple-app-site-association \
-u https://example.com/articles/123
When the pattern matches, swcutil tells you. If it says the pattern was blocked or did not match, fix the AASA file before touching SwiftUI.
Check the path you are actually tapping
Universal Link bugs love tiny mismatches:
/article/123is not/articles/123/Articles/123is not/articles/123/productsmay not match/products/*example.comis notwww.example.com- a marketing redirect link is not the final Universal Link until the browser follows it
If you are using the legacy paths array, remember that path comparison is path-based; query strings and fragments are not part of that legacy path match. If your routing depends on ?token=..., prefer the modern components format and match the query item there.
I usually start with one broad pattern, prove delivery, then narrow it:
{
"/": "/articles/*",
"comment": "Open every article while testing."
}
Once that works, add exclusions and query matching. With components, order matters and the first match wins, so put exclusions and more-specific rules before broad includes.
Test like iOS tests
Typing a URL into Safari’s address bar is not a valid Universal Link test. That is direct browser navigation, and iOS keeps it in the browser.
For quick testing, paste the link into Notes or Messages and tap it. Even better, long-press the link. If iOS recognizes the association, the menu should offer a way to open the link in your app as well as in the browser. That long-press menu is a lovely little truth serum.
The simulator can be useful for fast app-side testing:
xcrun simctl openurl booted "https://example.com/articles/123"
I still confirm on a physical device before calling the setup done. Device behavior includes the real associated-domain approval state, user choice, and Apple’s associated domains cache.
On a device, open Settings > Developer and look for the Universal Links diagnostics tools. Apple’s diagnostics can tell you whether a full URL is valid for an installed app, which is much faster than making hopeful changes and tapping the same link twenty more times.
Respect the cache
This is the part that makes people feel like they are losing their minds. Starting with iOS 14, associated-domain files are fetched through an Apple-managed CDN. Apple says the CDN requests the file for your domain within 24 hours, and devices check for updates approximately once per week after app installation.
That means you can fix the AASA file and still test against cached data for a while.
When debugging, try this rhythm:
- fix the AASA file on the server
- verify it with
curl - verify the path with
swcutil - delete the app from the device
- install a fresh build
- test from Notes or Messages
For private staging servers, or when you need to iterate faster than the CDN, use the Associated Domains developer mode:
applinks:staging.example.com?mode=developer
Then enable the related Associated Domains Development setting on the device. I keep that out of production entitlements, but it is extremely useful while a staging AASA file is still moving around.
Log the SwiftUI handoff
Once iOS accepts the link, SwiftUI receives it through onOpenURL. Put the handler near the root of your app so it exists for cold launches and foreground delivery. The sample below uses NavigationStack and assumes iOS 16 or newer; for multi-window apps, make sure the scene that owns the navigation state is the scene handling the incoming URL, and consider scene-level routing tools like handlesExternalEvents where they fit your app.
import OSLog
import SwiftUI
private let logger = Logger(subsystem: "com.example.MyApp", category: "DeepLinks")
enum AppRoute: Hashable {
case article(id: String)
case product(id: String)
}
@MainActor
final class LinkRouter: ObservableObject {
@Published var path = NavigationPath()
func handle(_ url: URL) {
logger.info("Received URL: \(url.absoluteString, privacy: .public)")
guard url.scheme == "https",
let host = url.host,
["example.com", "www.example.com"].contains(host) else {
logger.warning("Ignoring unsupported URL: \(url.absoluteString, privacy: .public)")
return
}
let parts = url.pathComponents.filter { $0 != "/" }
switch parts {
case ["articles", let id]:
path.append(AppRoute.article(id: id))
case ["products", let id]:
path.append(AppRoute.product(id: id))
default:
logger.warning("No route for path: \(url.path, privacy: .public)")
}
}
}
struct RootView: View {
@StateObject private var router = LinkRouter()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .article(let id):
ArticleView(id: id)
case .product(let id):
ProductView(id: id)
}
}
}
.onOpenURL { url in
router.handle(url)
}
}
}
Now the failure tells you where to look. If you do not see Received URL, the problem is before SwiftUI: entitlement, AASA, path matching, testing method, user approval, or cache. If you do see it, the Universal Link is working and your bug is inside your route parser or navigation state.
For deeper system logs, connect the device, open Console or Xcode’s device console, and filter for your domain, bundle ID, or swcd. A sysdiagnose can also include associated-domain state, but I reach for that after the simpler checks above.
Universal Links are not one setting. They are a handshake between signing, hosting, path rules, cache state, user behavior, and your app router. Start at the entitlement, prove the AASA response, test a single known URL, log onOpenURL, and only then get fancy with redirects, query matching, and multiple domains. Boring, direct, and verified is the fastest path out of Universal Link confusion.