Deep Linking: Connecting the Web to Your Mobile App
Implement deep linking that seamlessly routes users from web links, emails, and notifications into specific screens in your mobile app. Covers Universal Links, App Links, deferred deep linking, link routing architectures, and the testing strategies that prevent deep links from silently breaking.
A deep link is a URL that opens a specific screen in your mobile app instead of a generic web page. When a user taps a link in an email, a notification, or a social media post, deep linking takes them directly to the relevant content — not to the app’s home screen where they have to navigate manually.
Deep linking converts external touchpoints (marketing emails, push notifications, social shares) into in-app engagement. Without it, every external link sends users to the web or to a generic app launch.
Types of Deep Links
Standard Deep Links (URI Schemes)
myapp://orders/456
myapp://products/boots-vintage?color=black
Pros: Simple to implement. Cons: Only works if the app is installed. Uninstalled users see an error.
Universal Links (iOS) / App Links (Android)
Standard HTTPS URLs that open the app when installed and fall back to the web when not:
https://example.com/orders/456
On iOS, if the app is installed, this opens the app directly. If not, it opens Safari showing the web version. Same behavior on Android with App Links.
Deferred Deep Links
Route users to the correct screen even if the app is not yet installed:
1. User taps https://example.com/orders/456
2. App is not installed → redirect to App Store
3. User installs the app
4. App opens → navigates to orders/456 (the original destination)
This requires a deep linking service that stores the intent across the install boundary.
iOS Universal Links
Configuration
- Apple App Site Association (AASA) file on your web server:
// https://example.com/.well-known/apple-app-site-association
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.example.myapp",
"paths": [
"/orders/*",
"/products/*",
"/profile/*"
]
}
]
}
}
- Entitlement in the app:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:example.com</string>
</array>
- Handle the link in AppDelegate:
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return false }
return DeepLinkRouter.handle(url)
}
Android App Links
Configuration
- Digital Asset Links file on your web server:
// https://example.com/.well-known/assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": ["AB:CD:EF:..."]
}
}]
- Intent filter in AndroidManifest.xml:
<activity android:name=".MainActivity">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="example.com"
android:pathPrefix="/orders" />
</intent-filter>
</activity>
- Handle in Activity:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent?.data?.let { uri ->
DeepLinkRouter.handle(uri)
}
}
Link Routing Architecture
Centralize deep link handling in a router:
class DeepLinkRouter {
static func handle(_ url: URL) -> Bool {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return false
}
let pathComponents = components.path.split(separator: "/").map(String.init)
switch pathComponents.first {
case "orders":
if let orderId = pathComponents.dropFirst().first {
navigateTo(.orderDetail(id: orderId))
return true
}
navigateTo(.orderList)
return true
case "products":
if let productSlug = pathComponents.dropFirst().first {
let color = components.queryItems?.first(where: { $0.name == "color" })?.value
navigateTo(.productDetail(slug: productSlug, color: color))
return true
}
navigateTo(.productCatalog)
return true
case "profile":
navigateTo(.profile)
return true
default:
return false
}
}
}
Handling Authentication
Some deep links require authentication:
case "orders":
if authManager.isLoggedIn {
navigateTo(.orderDetail(id: orderId))
} else {
// Save destination, show login, then redirect
pendingDeepLink = .orderDetail(id: orderId)
navigateTo(.login)
}
After successful login, check for a pending deep link and navigate.
Deferred Deep Linking
For users who do not have the app installed:
Tap link → Web landing page
↓
"Open in App" banner (Smart App Banner on iOS)
↓
Install from App Store / Play Store
↓
First launch → navigate to original deep link destination
Implementation Options
- Branch.io: Full-featured deep linking platform with attribution
- Firebase Dynamic Links (deprecated, migrating to short links)
- Custom solution: Store link data in clipboard or server-side session
Custom Deferred Deep Linking
// Web: Store deep link intent before redirect to app store
const deepLinkData = {
destination: '/orders/456',
timestamp: Date.now(),
campaign: 'spring_sale'
};
// Store in server session tied to device fingerprint
fetch('/api/deferred-deeplink', {
method: 'POST',
body: JSON.stringify(deepLinkData)
});
// Redirect to app store
window.location = 'https://apps.apple.com/app/myapp/id123456';
// App: Check for deferred deep link on first launch
func handleFirstLaunch() {
api.checkDeferredDeepLink(deviceId: getDeviceId()) { result in
if let destination = result?.destination {
DeepLinkRouter.navigate(to: destination)
}
}
}
Testing Deep Links
Manual Testing
# iOS Simulator
xcrun simctl openurl booted "https://example.com/orders/456"
# Android Emulator
adb shell am start -W -a android.intent.action.VIEW \
-d "https://example.com/orders/456" com.example.myapp
Automated Testing
// XCTest: Verify deep link routing
func testOrderDeepLink() {
let url = URL(string: "https://example.com/orders/456")!
let handled = DeepLinkRouter.handle(url)
XCTAssertTrue(handled)
XCTAssertEqual(navigationController.topViewController, OrderDetailViewController.self)
XCTAssertEqual(currentOrderId, "456")
}
Validation Checklist
- AASA/assetlinks.json is accessible via HTTPS (not redirect)
- Content-Type is
application/json(nottext/html) - App ID and package name match exactly
- All deep link paths are covered in routing
- Authentication redirect preserves the original destination
- Fallback to web works when app is not installed
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| URI schemes only | Broken for uninstalled users | Universal Links + App Links |
| No fallback web page | Dead link for non-app users | Every deep link has a web equivalent |
| Hardcoded routes | Brittle, hard to update | Centralized router with pattern matching |
| No auth handling | Login wall drops the deep link | Queue deep link, redirect after auth |
| No automated testing | Silent breakage on refactor | CI tests for all deep link patterns |
Deep linking is the bridge between your marketing channels and your app experience. Every email, every notification, every social share is an opportunity to land users exactly where they need to be. When it works, it feels like magic. When it breaks, it feels like a dead end.