Apps with deep linking see 2x higher conversion rates and 3x better retention. Yet 70% of apps don't implement deep links properly. Deep linking connects your app to the web, enables seamless user journeys, and unlocks powerful attribution. This guide shows you how to implement bulletproof deep linking for iOS and Android.
Deep Linking Overview
Why Deep Links Matter
- Better user experience: Direct users to specific content, not home screen
- Higher conversion: Reduce friction in user journeys
- Attribution tracking: Know which campaigns drive installs
- Deferred deep links: Work even if app not installed
- SEO benefits: Connect web and app content
- Smarter notifications: Link to relevant content
- Cross-platform: Work on web, iOS, Android
Types of Deep Links
1. URI Scheme (myapp://):
Format: myapp://product/123
Support: iOS, Android
Works: Only if app installed
Use case: Basic internal navigation
Pros:
✓ Simple to implement
✓ Works on all OS versions
✓ Good for app-to-app communication
Cons:
✗ Broken if app not installed
✗ Shows error dialog
✗ No web fallback
✗ Not indexed by search engines
2. Universal Links (iOS):
Format: https://example.com/product/123
Support: iOS 9+
Works: Opens app if installed, web if not
Use case: Production apps
Pros:
✓ Seamless fallback to web
✓ No error dialogs
✓ Secure (verified domains)
✓ SEO friendly
✓ Works in all apps
Cons:
✗ More complex setup
✗ Requires AASA file hosting
✗ Cache issues during development
3. App Links (Android):
Format: https://example.com/product/123
Support: Android 6.0+
Works: Opens app if installed, web if not
Use case: Production apps
Pros:
✓ Seamless fallback
✓ No disambiguation dialog
✓ Verified domains
✓ SEO friendly
Cons:
✗ Setup complexity
✗ Requires assetlinks.json
✗ Need website with SSL
4. Deferred Deep Links:
Format: Any of above
Support: Via SDKs (Branch, Firebase, etc.)
Works: Remembers link even if app not installed
Use case: Marketing campaigns
Flow:
1. User clicks link
2. App not installed → Go to App Store
3. User installs app
4. App opens to originally intended content
Pros:
✓ Perfect user experience
✓ Attribution preserved
✓ Higher conversion
Cons:
✗ Requires third-party SDK
✗ Privacy concerns (fingerprinting)
Recommendation:
- iOS: Universal Links
- Android: App Links
- Marketing: Deferred deep links (Branch/Firebase)
iOS Universal Links
Setup Process
Step 1: Create AASA file (Apple-App-Site-Association)
File: .well-known/apple-app-site-association
Location: https://yourdomain.com/.well-known/apple-app-site-association
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.yourcompany.yourapp",
"paths": [
"/product/*",
"/article/*",
"/user/*/profile",
"NOT /admin/*",
"NOT /api/*"
]
}
]
},
"webcredentials": {
"apps": [ "TEAMID.com.yourcompany.yourapp" ]
}
}
Important:
- No .json extension
- Must be served over HTTPS
- Content-Type: application/json
- No redirects allowed
- Must be in root /.well-known/ directory
- TEAMID: Find in developer.apple.com
- Bundle ID: com.yourcompany.yourapp
Path patterns:
- "*" = wildcard
- "?" = single character
- "NOT" = exclude pattern
- Case sensitive!
Test AASA:
https://branch.io/resources/aasa-validator/
iOS Implementation
// Step 2: Configure Xcode
1. Add Associated Domains capability:
- Select target
- Signing & Capabilities
- "+ Capability"
- Add "Associated Domains"
2. Add domains:
- applinks:yourdomain.com
- applinks:www.yourdomain.com
- (Don't include https://)
// Step 3: Handle Universal Links
// AppDelegate.swift
import UIKit
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
// Check if it's a universal link
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else {
return false
}
// Handle the deep link
return handleDeepLink(url)
}
func handleDeepLink(_ url: URL) -> Bool {
print("Deep link received: \(url)")
// Parse URL
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return false
}
let path = components.path
let queryItems = components.queryItems
// Route based on path
switch path {
case let path where path.hasPrefix("/product/"):
let productId = path.replacingOccurrences(of: "/product/", with: "")
openProduct(productId)
return true
case let path where path.hasPrefix("/article/"):
let articleId = path.replacingOccurrences(of: "/article/", with: "")
openArticle(articleId)
return true
case "/user/profile":
if let userId = queryItems?.first(where: { $0.name == "id" })?.value {
openUserProfile(userId)
return true
}
default:
// Unknown path, open home
openHome()
return true
}
return false
}
func openProduct(_ productId: String) {
// Navigate to product screen
NotificationCenter.default.post(
name: NSNotification.Name("OpenProduct"),
object: nil,
userInfo: ["productId": productId]
)
}
func openArticle(_ articleId: String) {
// Navigate to article screen
NotificationCenter.default.post(
name: NSNotification.Name("OpenArticle"),
object: nil,
userInfo: ["articleId": articleId]
)
}
func openUserProfile(_ userId: String) {
// Navigate to user profile
NotificationCenter.default.post(
name: NSNotification.Name("OpenUserProfile"),
object: nil,
userInfo: ["userId": userId]
)
}
func openHome() {
// Navigate to home
NotificationCenter.default.post(
name: NSNotification.Name("OpenHome"),
object: nil
)
}
}
// Step 4: Handle in SwiftUI (optional)
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
handleDeepLink(url)
}
}
}
func handleDeepLink(_ url: URL) {
// Same routing logic as AppDelegate
print("Deep link: \(url)")
}
}
Testing Universal Links
Testing checklist:
1. Validate AASA file:
https://branch.io/resources/aasa-validator/
Enter: yourdomain.com
Check: AASA valid ✓
2. Test on device (not simulator):
- Universal Links don't work well in simulator
- Use real device
3. Test methods:
a) Notes app:
- Create note
- Type: https://yourdomain.com/product/123
- Long press → Open
- Should open app (not Safari)
b) Messages:
- Send link to yourself
- Tap link
- Should open app
c) Safari:
- Type URL in address bar
- Pull down banner "Open in App"
- Or Smart App Banner
d) Terminal:
xcrun simctl openurl booted "https://yourdomain.com/product/123"
4. Debugging:
// Enable debug logging
Settings → Developer → Universal Links
→ Enable "Associated Domains Development"
// View logs in Console.app
Filter: swcd
5. Common issues:
❌ Opens in Safari instead of app
Fix: Check AASA is accessible, correct bundle ID, correct Team ID
❌ AASA not loading
Fix: Ensure HTTPS, no redirects, correct Content-Type
❌ Works first time, then stops
Fix: iOS caches AASA. Delete app, reinstall
❌ Works in Messages, not Safari
Fix: Expected! Safari requires Smart App Banner or pull-down
Android App Links
Setup Process
Step 1: Create assetlinks.json
File: .well-known/assetlinks.json
Location: https://yourdomain.com/.well-known/assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.yourapp",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
]
}
}]
Get SHA256 fingerprint:
Debug keystore:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
Release keystore:
keytool -list -v -keystore your-release-keystore.jks -alias your-alias
Or from Google Play Console:
→ Release Management → App Signing
→ App signing certificate (SHA-256)
Test assetlinks:
https://yourdomain.com/.well-known/assetlinks.json
Android Implementation
// Step 2: Configure AndroidManifest.xml
Key attributes:
- android:autoVerify="true" - Enables App Links
- launchMode="singleTask" - Reuses existing activity
// Step 3: Handle Deep Links
// MainActivity.kt
import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Handle intent on launch
handleIntent(intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// Handle intent when app already running
intent?.let { handleIntent(it) }
}
private fun handleIntent(intent: Intent) {
val action = intent.action
val data: Uri? = intent.data
// Check if it's a VIEW action (deep link)
if (action == Intent.ACTION_VIEW && data != null) {
handleDeepLink(data)
}
}
private fun handleDeepLink(uri: Uri) {
println("Deep link received: $uri")
val scheme = uri.scheme // "https" or "myapp"
val host = uri.host // "yourdomain.com"
val path = uri.path // "/product/123"
val queryParams = uri.queryParameterNames
// Route based on path
when {
path?.startsWith("/product/") == true -> {
val productId = path.substringAfter("/product/")
openProduct(productId)
}
path?.startsWith("/article/") == true -> {
val articleId = path.substringAfter("/article/")
openArticle(articleId)
}
path == "/user/profile" -> {
val userId = uri.getQueryParameter("id")
userId?.let { openUserProfile(it) }
}
else -> {
// Unknown path, open home
openHome()
}
}
}
private fun openProduct(productId: String) {
// Navigate to product screen
// Using Navigation component:
// findNavController().navigate(
// R.id.action_to_product,
// bundleOf("productId" to productId)
// )
}
private fun openArticle(articleId: String) {
// Navigate to article screen
}
private fun openUserProfile(userId: String) {
// Navigate to user profile
}
private fun openHome() {
// Navigate to home
}
}
Testing App Links
Testing checklist:
1. Verify assetlinks.json:
https://yourdomain.com/.well-known/assetlinks.json
- Should be accessible
- Should return JSON (not HTML)
2. Test App Links verification:
adb shell pm get-app-links com.yourcompany.yourapp
Should show:
yourdomain.com: verified
If "undefined":
adb shell pm verify-app-links --re-verify com.yourcompany.yourapp
3. Test deep link via ADB:
adb shell am start -W -a android.intent.action.VIEW \
-d "https://yourdomain.com/product/123" \
com.yourcompany.yourapp
4. Test in Chrome:
- Type URL in address bar
- Tap "Open in app" banner
- Or long-press link → "Open in app"
5. Test in other apps:
- Gmail: Click link in email
- Messages: Click link in SMS
- Twitter/X: Click link in tweet
6. Debug App Links:
adb shell dumpsys package d
Look for your domain under "Domain verification state"
7. Common issues:
❌ Shows disambiguation dialog
Fix: Domain not verified. Check assetlinks.json, SHA256 fingerprint
❌ Opens in Chrome instead
Fix: autoVerify="true" missing, or verification failed
❌ assetlinks.json returns 404
Fix: Ensure file is at /.well-known/assetlinks.json
❌ Verification status "undefined"
Fix: Force re-verify with adb command above
Deferred Deep Links
Firebase Dynamic Links
Why use Firebase Dynamic Links:
✓ Works if app not installed (deferred)
✓ Cross-platform (iOS, Android, Web)
✓ Short links (great for sharing)
✓ Analytics built-in
✓ Free (for now)
✓ No SDK size bloat
Setup:
1. Firebase Console:
- Enable Dynamic Links
- Add domain (page.link or custom)
2. iOS Installation:
pod 'Firebase/DynamicLinks'
// AppDelegate
import Firebase
import FirebaseDynamicLinks
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
FirebaseApp.configure()
return true
}
// Handle dynamic link
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey : Any] = [:]
) -> Bool {
return handleDynamicLink(url)
}
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
guard let url = userActivity.webpageURL else { return false }
return handleDynamicLink(url)
}
func handleDynamicLink(_ url: URL) -> Bool {
DynamicLinks.dynamicLinks().handleUniversalLink(url) { dynamicLink, error in
guard error == nil, let dynamicLink = dynamicLink else {
return
}
if let url = dynamicLink.url {
// Handle deep link
self.handleDeepLink(url)
}
}
return true
}
3. Android Installation:
dependencies {
implementation 'com.google.firebase:firebase-dynamic-links-ktx:21.2.0'
}
// MainActivity
import com.google.firebase.dynamiclinks.ktx.*
import com.google.firebase.ktx.Firebase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Firebase.dynamicLinks
.getDynamicLink(intent)
.addOnSuccessListener { pendingDynamicLinkData ->
val deepLink = pendingDynamicLinkData?.link
deepLink?.let {
handleDeepLink(it)
}
}
}
4. Create Dynamic Link:
// iOS
func createDynamicLink(productId: String) -> URL? {
guard let link = URL(string: "https://yourdomain.com/product/\(productId)") else {
return nil
}
let components = DynamicLinkComponents(link: link, domainURIPrefix: "https://yourapp.page.link")
// iOS parameters
components.iOSParameters = DynamicLinkIOSParameters(bundleID: "com.yourcompany.yourapp")
components.iOSParameters?.appStoreID = "123456789"
components.iOSParameters?.minimumAppVersion = "1.0"
// Android parameters
components.androidParameters = DynamicLinkAndroidParameters(packageName: "com.yourcompany.yourapp")
components.androidParameters?.minimumVersion = 1
// Social meta tags
components.socialMetaTagParameters = DynamicLinkSocialMetaTagParameters()
components.socialMetaTagParameters?.title = "Check out this product!"
components.socialMetaTagParameters?.imageURL = URL(string: "https://yourdomain.com/image.jpg")
// Analytics
components.analyticsParameters = DynamicLinkGoogleAnalyticsParameters(source: "app", medium: "social", campaign: "share")
// Short link options
components.options = DynamicLinkComponentsOptions()
components.options?.pathLength = .short
// Generate long link (instant)
let longLink = components.url
// Generate short link (async)
components.shorten { url, warnings, error in
if let error = error {
print("Error: \(error)")
return
}
if let shortURL = url {
print("Short link: \(shortURL)")
// Share this URL
}
}
return longLink
}
// Android
fun createDynamicLink(productId: String): Uri {
val deepLink = "https://yourdomain.com/product/$productId"
return Firebase.dynamicLinks.shortLinkAsync {
link = Uri.parse(deepLink)
domainUriPrefix = "https://yourapp.page.link"
androidParameters("com.yourcompany.yourapp") {
minimumVersion = 1
}
iosParameters("com.yourcompany.yourapp") {
appStoreId = "123456789"
minimumVersion = "1.0"
}
socialMetaTagParameters {
title = "Check out this product!"
imageUrl = Uri.parse("https://yourdomain.com/image.jpg")
}
googleAnalyticsParameters {
source = "app"
medium = "social"
campaign = "share"
}
}.await().shortLink ?: Uri.parse(deepLink)
}
Usage:
// Share button
button.setOnClickListener {
val link = createDynamicLink("product_123")
val shareIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, link.toString())
type = "text/plain"
}
startActivity(Intent.createChooser(shareIntent, "Share"))
}
Branch.io Integration
Why use Branch:
✓ Best deferred deep linking
✓ Attribution tracking
✓ Cross-platform
✓ Powerful analytics
✓ A/B testing
✗ Paid (free tier limited)
Quick setup:
1. Sign up at branch.io
2. Get API key
iOS:
pod 'Branch'
// AppDelegate
import Branch
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
Branch.getInstance().initSession(launchOptions: launchOptions) { params, error in
if let params = params as? [String: AnyObject] {
// Handle deep link data
if let productId = params["product_id"] as? String {
self.openProduct(productId)
}
}
}
return true
}
Android:
implementation 'io.branch.sdk.android:library:5.+'
// Application
import io.branch.referral.Branch
override fun onCreate() {
super.onCreate()
Branch.getAutoInstance(this)
}
// MainActivity
override fun onStart() {
super.onStart()
Branch.sessionBuilder(this).withCallback { referringParams, error ->
if (error == null && referringParams != null) {
val productId = referringParams.optString("product_id")
if (productId.isNotEmpty()) {
openProduct(productId)
}
}
}.withData(intent?.data).init()
}
Create Branch link:
val linkProperties = LinkProperties()
.setChannel("app")
.setFeature("sharing")
.addControlParameter("product_id", "123")
.addControlParameter("$og_title", "Amazing Product")
.addControlParameter("$og_image_url", "https://...")
Branch.getInstance().generateShortUrl(this, linkProperties) { url, error ->
if (error == null) {
// Share url
}
}
Attribution & Analytics
Tracking Link Performance
Track these metrics:
1. Click-through rate (CTR):
Clicks / Impressions
Goal: > 5%
2. Install rate:
Installs / Clicks
Goal: > 20%
3. Open rate:
App Opens / Clicks
Goal: > 80% (existing users)
4. Conversion rate:
Conversions / Clicks
Goal: Varies by action
5. Deep link success rate:
Successful deep links / Attempts
Goal: > 95%
Implementation:
// Log deep link received
Analytics.logEvent("deep_link_received", parameters: [
"url": url.absoluteString,
"path": url.path,
"source": getSource(url),
"campaign": getCampaign(url)
])
// Log deep link success
Analytics.logEvent("deep_link_opened", parameters: [
"destination": "product_123",
"success": true
])
// Log deep link failure
Analytics.logEvent("deep_link_failed", parameters: [
"url": url.absoluteString,
"error": error.localizedDescription
])
Campaign tracking:
URL: https://yourdomain.com/product/123?utm_source=email&utm_campaign=summer_sale
func getUTMParameters(_ url: URL) -> [String: String] {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let queryItems = components.queryItems else {
return [:]
}
var params: [String: String] = [:]
for item in queryItems {
if item.name.hasPrefix("utm_") {
params[item.name] = item.value
}
}
return params
}
// Track campaign
let utmParams = getUTMParameters(url)
Analytics.logEvent("deep_link_campaign", parameters: utmParams)
Best Practices
Deep Link Checklist
Technical:
□ AASA/assetlinks.json served over HTTPS
□ Correct Team ID / SHA256 fingerprint
□ Associated Domains configured
□ Intent filters with autoVerify
□ Handle both onCreate and onNewIntent
□ Handle both UIApplication methods
□ Test on real devices
□ Graceful fallbacks for errors
□ Loading states while navigating
□ Deep link logging/analytics
UX:
□ Direct users to intended content
□ Handle auth requirements gracefully
□ Show loading indicator
□ Error handling (content not found)
□ Back button works correctly
□ Works from cold start
□ Works from background
□ Works when app already on screen
Marketing:
□ Use short links for sharing
□ UTM parameters for tracking
□ Social meta tags set
□ Preview images optimized
□ A/B test link variations
□ Track conversion by source
□ Monitor deep link success rate
Security:
□ Validate all input from URLs
□ Don't expose sensitive data in URLs
□ Check user permissions before showing content
□ Rate limit deep link handling
□ Log suspicious deep link patterns
Conclusion
Deep linking transforms user experience by eliminating friction and enabling seamless navigation between web and app. Universal Links and App Links provide the foundation, while deferred deep links unlock powerful attribution. Implement deep linking correctly once, and every marketing campaign, notification, and user journey becomes more effective. The investment pays dividends in higher conversion rates and better user retention.
Deep links connect users to your app, but users still need support. Our support URL generator creates the professional support pages required for App Store and Google Play, ensuring your deep link strategy complements a complete user experience.