Back to all articles

Mobile App Deep Linking & Universal Links: Complete Guide 2025

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.

Need a Support URL for Your App?

Generate a compliant, professional support page in under a minute. Our easy-to-use generator creates everything you need for App Store and Google Play submissions.