When someone says “deep linking” on iOS, they could mean one of three things: custom URL scheme, Universal Link, or App Clip. Each was designed for a different era, each solves a different problem.
I’ve shipped all three in production over the last decade. Here’s when to pick which.
Custom URL Scheme (old, still used)
The original approach, around since the very first iOS. You register a URL scheme in Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>Now myapp://user/123 opens your app. Simple.
Pros:
– Minimal setup
– No server-side work
– Great for app-to-app communication (app X opens app Y)
Cons:
– Not secure: another app can register the same scheme. Malware could claim your banking app’s scheme.
– Unreliable: on a scheme conflict iOS picks arbitrarily.
– Not a web link: you can’t click one on a web page (Safari says “unknown URL”).
– The legacy cousin: Apple is steadily deprecating custom URL schemes.
Where it’s still reasonable:
- App-to-app communication (your main app talking to your extension app)
- OAuth redirects (provider back to the app callback)
- Legacy system integrations
Don’t reach for it on a greenfield project. Universal Link exists for a reason.
Universal Link (modern, recommended)
Introduced in iOS 9 (2015). The “officially recommended” deep link approach today.
Four-step setup:
Step 1: put /.well-known/apple-app-site-association on your server:
{
"applinks": {
"details": [
{
"appIDs": ["TEAM_ID.com.example.app"],
"components": [
{ "/": "/user/*" },
{ "/": "/product/*" }
]
}
]
}
}JSON, served with content-type application/json.
Step 2: add the Associated Domains entitlement in Xcode Signing & Capabilities:
applinks:example.comStep 3: handle the URL in AppDelegate or the SwiftUI App lifecycle:
.onOpenURL { url in
if url.pathComponents.contains("user") {
let userId = url.lastPathComponent
navigateToUser(userId)
}
}Step 4: test (the previous post covered that in detail).
Pros:
– Web links work: Safari, Mail, WhatsApp, anywhere
– Secure: only apps associated with your domain can catch the link
– Fallback: without the app installed, the web page opens
– SEO-friendly: regular URLs
Cons:
– Requires server setup (serve the AASA file)
– Debugging is rough (AASA caching)
– No deferred deep linking out of the box
App Clips (for specific scenarios)
Introduced in iOS 14 (2020). App functionality without the app.
It’s a special variant of Universal Link. When the URL is opened and the app isn’t installed, the App Clip downloads and runs (instead of the full app).
The use case: a user at a specific physical place wants to do a specific thing (payment, rental). They’ve never installed your app. The App Clip opens in five seconds and they’re done.
When to use it: I covered this in depth in the App Clips post. Short version: offline payment, event entry, scooter rental, and similar.
The decision tree
For deep linking on a new iOS project:
- Will a web page link into the app? Universal Link.
- An OAuth callback or other one-off redirect? Custom URL scheme.
- Physical interaction (NFC, QR) plus a one-shot task? App Clip.
- Native-to-native (another of your apps) communication? Custom URL scheme.
- Standard engagement from a link? Universal Link.
Default to Universal Link. The others are exceptions.
Designing the link structure
How should you shape your deep link URLs? A rule:
Good: https://example.com/user/123
– Meaningful on the web (user profile page)
– Easy to parse on the app side
– Backwards-compatible if you start versioning
Bad: https://example.com/?action=openUser&id=123
– No path on the web deployment
– Query params aren’t concise or semantic
– Tracking is harder in analytics
Path hierarchy:
/user/123 # User profile
/user/123/posts # User's posts
/user/123/posts/456 # Specific post
/product/xyz # Product
/product/xyz/reviews # Reviews
/checkout/cart # Cart
/checkout/payment # Payment flowThe same hierarchy on the web and in the app. Parsing code stays standard.
Parameter parsing
Query parameters can carry extra context:
https://example.com/product/xyz?ref=email_campaign&variant=bOn the app side, parse them:
func handle(_ url: URL) {
let components = URLComponents(url: url, resolvingAgainstBaseURL: true)
let queryItems = components?.queryItems ?? []
var ref: String?
var variant: String?
for item in queryItems {
switch item.name {
case "ref": ref = item.value
case "variant": variant = item.value
default: break
}
}
// Attribution tracking
if let ref = ref {
analytics.track("deep_link_arrival", properties: ["ref": ref])
}
}That’s how you attribute marketing campaigns.
Testing strategy
Development:
– Simulator or a physical device
– Universal Link developer mode (the query-string trick)
– Confirm the AASA file is being served correctly (curl it)
– Entitlements match the right provisioning profile
Staging:
– AASA served from a real domain
– Test with a TestFlight build
– Real network, not a localhost proxy
Production verification:
– Smoke test: tap links from different places (Mail, Safari, Notes, WhatsApp)
– Analytics: are deep link arrival events coming in?
– Fallback: with the app uninstalled, does the web page open?
Migration: URL scheme to Universal Link
When an older app has a custom URL scheme and you want to move to Universal Link:
- Coexist: both systems can run together. Keep the old URL scheme handler, add a new Universal Link handler.
- Gradual migration: email templates and push payloads shift to Universal Link URLs one by one.
- Sunset: after 6 to 12 months, deprecate the URL scheme. Users still hitting the old URLs get an “update your app” message.
That transition takes two or three sprints, but it’s worth it for security and UX.
Takeaway
Deep linking on iOS is a bigger topic than it looks. Picking the right technology (Universal Link by default, URL scheme niche), designing good URLs, testing across the matrix.
Start every new project with Universal Link. Plan migrations for existing projects. Only use App Clip for a genuine use case. Save custom URL scheme for legacy or OAuth.