UIKit12 min readJul 5, 2026

Mastering Deep Links in iOS: A Comprehensive UIKit Guide

Deep linking allows your iOS application to respond to specific URLs, directing users to particular content within your app. This guide covers implementing custom URL schemes and Universal Links in UIKit, providing a robust pathway for enhanced app navigation and user engagement.

In today's mobile-first world, providing a frictionless user experience is paramount. Deep links are a fundamental tool in achieving this, allowing users to jump directly into specific content within your iOS application from external sources like websites, emails, or other apps. Without deep links, tapping a link might only open your app to its default entry point, forcing the user to navigate manually to their desired content. This creates friction and can lead to user abandonment.

Deep links significantly improve user experience by offering instant access to relevant information, whether it's a specific product page, a user's profile, or a particular notification. They are crucial for features like password reset emails, referral programs, and sharing content directly to your app. For developers, understanding and implementing deep links effectively is a core skill for building robust, user-friendly iOS applications.

Understanding Custom URL Schemes

Custom URL schemes are the oldest form of deep linking on iOS. They allow you to register a unique scheme (e.g., myapp://) that your app will respond to. When a user taps a link with your custom scheme, iOS launches your app (if installed) and passes the URL to it. If the app is not installed, nothing happens, which is a major drawback.

To implement a custom URL scheme, you need to configure your app's Info.plist file. You'll add a new URL Types array, specifying an identifier and the URL scheme itself. This acts as a registration mechanism with the operating system, telling iOS which application should handle URLs prefixed with your chosen scheme.

Accessing the URL in your app depends on its lifecycle state:

  • App launched from background/not running: The application(_:didFinishLaunchingWithOptions:) method in your AppDelegate will receive the URL in the launchOptions dictionary, under the UIApplication.LaunchOptionsKey.url key.
  • App already running in foreground/background: The application(_:open:options:) method in your AppDelegate (or scene(_:openURLContexts:) for UISceneDelegate based apps) will be called.

Let's walk through the Info.plist setup and how to handle these URLs within your AppDelegate or SceneDelegate.

Compatibility: Custom URL Schemes have been available since iOS 2.0. They are fully supported across all modern iOS versions (iOS 13+ for UISceneDelegate).

swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        if let url = launchOptions?[.url] as? URL {
            handleIncomingURL(url)
        }
        return true
    }

    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
        handleIncomingURL(url)
        return true
    }

    func handleIncomingURL(_ url: URL) {
        print("Received custom URL scheme: \(url.absoluteString)")
        // Example: myapp://profile?id=123
        if url.scheme == "myapp" {
            if url.host == "profile" {
                if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
                   let queryItems = components.queryItems {
                    for item in queryItems {
                        if item.name == "id", let id = item.value {
                            print("Navigating to profile with ID: \(id)")
                            // Present a profile view controller with ID
                            // let profileVC = ProfileViewController(profileId: id)
                            // (self.window?.rootViewController as? UINavigationController)?.pushViewController(profileVC, animated: true)
                        }
                    }
                }
            }
        } else {
            print("Unknown scheme or path for: \(url.absoluteString)")
        }
    }
}

Universal Links, introduced in iOS 9, are Apple's preferred deep linking mechanism. They address the major shortcomings of custom URL schemes. Universal Links are standard HTTP/HTTPS links (e.g., https://www.example.com/products/123) that work like regular web links. If your app is installed and configured correctly, tapping a Universal Link will open your app to the specified content. If your app is not installed, the link will gracefully fall back to opening the same content in Safari.

This seamless fallback is a huge advantage, providing a consistent experience regardless of whether the user has your app installed. Setting up Universal Links requires both client-side (your iOS app) and server-side configuration.

Client-Side Configuration (App):

  1. Enable Associated Domains capability: In Xcode, go to your project target's Signing & Capabilities tab, click + Capability, and add Associated Domains. Add your domain prefixed with applinks: (e.g., applinks:www.example.com).
  2. Implement application(_:continue:restorationHandler:) or scene(_:continue:): Your app needs to handle incoming NSUserActivity objects with an activityType of NSUserActivityTypeBrowsingWeb. The webpageURL property of this activity will contain the Universal Link.

Server-Side Configuration:

You need to host a apple-app-site-association (AASA) file at the root of your web server (e.g., https://www.example.com/apple-app-site-association) or within a .well-known subdirectory (e.g., https://www.example.com/.well-known/apple-app-site-association). This JSON file tells iOS which app bundle IDs are allowed to handle which paths on your domain.

Important: The AASA file must be served over HTTPS without any redirects, and with the Content-Type: application/json header, or application/pkcs7-mime if signed. The JSON structure specifies the app ID (Team ID.Bundle ID) and an array of paths your app can handle.

Compatibility: Universal Links are available from iOS 9.0 onwards. The UISceneDelegate methods are for iOS 13+.

swift
import UIKit
import CoreSpotlight // Not strictly for Universal Links, but common in related activities

// For apps using UISceneDelegate (iOS 13+)
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
        guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
              let incomingURL = userActivity.webpageURL else {
            return
        }
        print("Received Universal Link: \(incomingURL.absoluteString)")
        handleUniversalLink(incomingURL)
    }

    func handleUniversalLink(_ url: URL) {
        print("Handling Universal Link: \(url.absoluteString)")
        // Example: https://www.example.com/products/123
        if url.host == "www.example.com" || url.host == "example.com" {
            let pathComponents = url.pathComponents
            if pathComponents.contains("products") {
                if let productIndex = pathComponents.firstIndex(of: "products"),
                   productIndex + 1 < pathComponents.count {
                    let productId = pathComponents[productIndex + 1]
                    print("Navigating to product ID: \(productId)")
                    // Push to a product detail view controller
                    // if let navController = window?.rootViewController as? UINavigationController {
                    //     let productVC = ProductDetailViewController(productId: productId)
                    //     navController.pushViewController(productVC, animated: true)
                    // }
                }
            }
        }
    }
}

// For apps still using AppDelegate (older projects or specific uses)
extension AppDelegate {
    func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
        guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
              let incomingURL = userActivity.webpageURL else {
            return false
        }
        print("Received Universal Link (AppDelegate): \(incomingURL.absoluteString)")
        // You would call handleUniversalLink from here as well, possibly passing to a shared handler
        // (self.window?.rootViewController as? UINavigationController)?.perform("handleUniversalLink:", with: incomingURL)
        return true
    }
}

json
// example-app-site-association (AASA) file content
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "YOUR_TEAM_ID.com.yourcompany.yourapp",
        "paths": [
          "/products/*",
          "/profile/*",
          "/onboarding/welcome",
          "/offers/*",
          "NOT /profile/settings/*"
        ]
      }
    ]
  }
}

While this article focuses on UIKit, it's worth noting how deep links are handled in a SwiftUI context, especially if you're mixing UIKit and SwiftUI or migrating. In SwiftUI, you'd typically use the .onOpenURL view modifier to handle URLs that open your app. This modifier can respond to both custom URL schemes and Universal Links.

For UISceneDelegate based SwiftUI apps, the scene(_:openURLContexts:) methods (for custom schemes) and scene(_:continue:) (for Universal Links) still get called first. You would then pass these URLs down to your SwiftUI view hierarchy, perhaps by injecting them into an EnvironmentObject or using the onOpenURL modifier directly on your main view.

When onOpenURL is triggered, you can parse the URL and update your view's state accordingly, driving navigation within your SwiftUI app. This approach leverages SwiftUI's declarative nature to handle routing based on the incoming URL.

Compatibility: The .onOpenURL modifier is available from iOS 14.0. For earlier SwiftUI versions (iOS 13), you'd rely more heavily on SceneDelegate or AppDelegate and pass data down through observable objects.

swift
import SwiftUI

struct ContentView: View {
    @State private var selectedTab: String = "home"
    @State private var productId: String? = nil

    var body: some View {
        TabView(selection: $selectedTab) {
            HomeView()
                .tabItem { Label("Home", systemImage: "house.fill") }
                .tag("home")

            NavigationView {
                ProductListView(selectedProductId: $productId)
            }
            .tabItem { Label("Products", systemImage: "bag.fill") }
            .tag("products")
        }
        .onOpenURL { url in
            handleDeepLink(url)
        }
    }

    private func handleDeepLink(_ url: URL) {
        print("SwiftUI received URL: \(url.absoluteString)")
        // Example: myapp://products?id=456 or https://www.example.com/products/789
        if let host = url.host, (host == "products" || host == "www.example.com") {
            if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
               let queryItems = components.queryItems {
                if let idItem = queryItems.first(where: { $0.name == "id" }), let id = idItem.value {
                    self.productId = id
                    self.selectedTab = "products" // Switch to products tab
                    print("Navigated to product ID: \(id)")
                }
            } else if url.pathComponents.contains("products"), let productIndex = url.pathComponents.firstIndex(of: "products"), productIndex + 1 < url.pathComponents.count {
                let id = url.pathComponents[productIndex + 1]
                self.productId = id
                self.selectedTab = "products"
                print("Navigated to product ID from path: \(id)")
            }
        } else if url.host == "profile" {
            self.selectedTab = "home" // Or a dedicated profile tab
            print("Navigated to profile")
        }
    }
}

struct ProductListView: View {
    @Binding var selectedProductId: String?

    var body: some View {
        VStack {
            Text("Product List")
            if let id = selectedProductId {
                NavigationLink(destination: ProductDetailView(productId: id), isActive: .constant(true)) {
                    EmptyView()
                }
                .hidden()
            }
        }
        .navigationTitle("Products")
    }
}

struct ProductDetailView: View {
    let productId: String

    var body: some View {
        Text("Detail for Product: \(productId)")
            .navigationTitle("Product \(productId)")
    }
}

struct HomeView: View {
    var body: some View {
        Text("Home Content")
            .navigationTitle("Home")
    }
}

Implementing deep links can sometimes be tricky, but following best practices and understanding common pitfalls will save you a lot of headache.

Best Practices:

  • Graceful Fallback: Always ensure your deep links have a web-based fallback. Universal Links handle this automatically. For custom URL schemes, you'll need to implement logic (e.g., a web page that redirects to the App Store if the app isn't installed).
  • Robust Parsing: Write flexible URL parsing logic. Account for cases where query parameters might be missing or in a different order. Use URLComponents for reliable parsing.
  • Idempotency: Ensure that handling a deep link multiple times doesn't cause unintended side effects. For example, navigating to a product page twice should just show the product page, not create two instances.
  • Analytics: Track deep link usage. This data can inform you about user journey, content popularity, and the effectiveness of your marketing campaigns.
  • Security: Be mindful of sensitive data in deep link URLs. Avoid passing API keys or unencrypted user data. If you need to pass sensitive information, consider using one-time tokens or server-side lookups.
  • Test Thoroughly: Test your deep links from various sources: Safari, Mail app, Messages, other apps, direct paste. Test both when your app is installed and not installed.

Common Troubleshooting Tips:

  • Universal Links not opening app:
    • Verify your AASA file: Check for correct JSON syntax, HTTPS, Content-Type, and no redirects. Use tools like curl -v https://yourdomain.com/apple-app-site-association to inspect.
    • Check appID: Ensure it's Team_ID.Bundle_ID in the AASA file and Xcode capabilities.
    • applinks: prefix: Is it present in Associated Domains?
    • Reinstall app: Sometimes iOS caches the AASA file; reinstalling forces a re-download.
    • Logs: Check Console for swcd (Shared Web Credentials Daemon) logs for AASA download errors.
  • Custom URL schemes not working:
    • Info.plist: Double-check your URL Types entry.
    • Delegate methods: Ensure application(_:open:options:) or scene(_:openURLContexts:) are correctly implemented and called.
  • Debugging deep links:
    • Use print statements in your URL handling methods.
    • For Universal Links, if it opens Safari, long-press the link to see if there's an "Open in [Your App Name]" banner/option.
    • Use the xcrun simctl openurl booted <URL> command in Terminal to test deep links on the simulator.

By following these guidelines, you can build a robust and reliable deep linking experience for your users.

Advanced Deep Linking Concepts

Beyond basic navigation, deep links can power more sophisticated features. Two primary advanced concepts are deferred deep linking and contextual deep linking.

Deferred Deep Linking: Deferred deep linking allows users who don't have your app installed to still reach the intended content after installing it. When a user taps a deep link, and the app isn't installed, they are redirected to the App Store. After installation, the app remembers the original deep link and navigates the user to the specific content they intended to see. This requires a third-party service (like Branch.io, Firebase Dynamic Links, or AppsFlyer) or a custom server-side implementation to store the original deep link and associate it with the user's installation post-App-Store download.

Contextual Deep Linking: Contextual deep linking builds on deferred deep linking by not only directing users to specific content but also providing additional context. For instance, if a user clicks a referral link, the deep link could navigate them to a product page and automatically apply a referral code or show a personalized welcome message. This enhances the user experience by making the interaction highly relevant and personalized from the very first launch.

These advanced techniques significantly boost user retention and conversion rates, making your app's acquisition funnels much more effective. They do, however, add complexity and often rely on external SDKs and server infrastructure. Carefully consider the trade-offs before implementing them.

Deep Links are just a fancy way to open an app.

Becoming a stronger iOS Engineer

THE MYTH or PROBLEM: Deep Links are just a fancy way to open an app.

Many developers view deep links merely as a way to launch an app, overlooking their critical role in user acquisition, engagement, and conversion. They often implement basic custom URL schemes without considering fallbacks or advanced features, leading to fractured user journeys.

swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Basic custom URL scheme handling
    if let url = launchOptions?[.url] as? URL {
        // Just open the app, no specific navigation
        print("App launched from URL: \(url.absoluteString)")
    }
    return true
}

WHAT HAPPENS INTERNALLY? Universal Link Resolution

When a Universal Link is tapped, iOS performs a series of checks to determine how to handle it, prioritizing the best user experience.

Universal Link Tap
iOS checks Associated Domains
AASA file downloaded/cached
Path matching in AASA
App Launch (on success)
Safari fallback (on failure)
1

1. Link Tap/Activation

User taps a Universal Link (e.g., from Mail, Safari, another app, iMessage).

2

2. Domain Check

iOS checks if the domain of the URL is registered in any installed app's 'Associated Domains' capability.

3

3. AASA File Lookup (if no cache)

If the AASA (apple-app-site-association) file for that domain isn't cached (or is outdated), iOS attempts to download it from `https://yourdomain.com/apple-app-site-association` or `/.well-known/apple-app-site-association`.

4

4. Path Matching

iOS consults the downloaded AASA file to see if the URL's path matches any of the paths listed for an installed app's bundle ID.

5

5. App Launch/Safari Fallback

If a match is found, the app is launched and notified via `application(_:continue:restorationHandler:)` or `scene(_:continue:)`. If no app is configured, or no match is found, the URL opens in Safari.

Visualized execution hierarchy.

Powerful Guarantees

Seamless User Experience

Guarantees a graceful fallback to web content if the app is not installed, preventing dead ends.

Enhanced Security

Requires server-side validation (AASA file) to prevent malicious apps from hijacking URLs.

Improved SEO (indirectly)

Supports consistent linking across web and app, improving discoverability and reducing friction for content indexed by search engines.

REAL PRODUCTION EXAMPLE: A common bug for Universal Links

A startup implemented Universal Links, but after deployment, users complained that clicking links from marketing emails always opened Safari, even with the app installed. They tested from iMessage, and it worked fine. The issue was that the email client was re-directing the URL via an intermediary tracking service, stripping the Universal Link context before it reached iOS.

Impact / Results
Frustrated users opening Safari instead of the app.
Lost deep link context, leading to generic app launch.
Reduced conversion rates for marketing campaigns.
THE FIX or SOLUTION
swift
// 1. Ensure marketing links use direct Universal Links or a service that preserves Universal Link behavior (e.g., Branch.io, Firebase Dynamic Links).
// 2. Validate all redirect services for Universal Link compatibility.
// 3. For any links that *must* redirect, ensure the final redirect *is* the Universal Link, and that the redirect does not prevent iOS from recognizing it.

// For the app, robustly parse the URL even if it has unexpected query parameters or fragments due to redirection.
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let incomingURL = userActivity.webpageURL else {
        return false
    }

    // Use URLComponents to parse reliably, ignoring extraneous fragments or query items that might be added by redirectors
    if let components = URLComponents(url: incomingURL, resolvingAgainstBaseURL: false) {
        print("Universal Link received: \(components.url?.absoluteString ?? "")")
        // Further deep link routing logic based on components.path, components.queryItems
        return true
    }
    return false
}

INTERVIEW PERSPECTIVE

Common Question

Explain the full lifecycle of a Universal Link from user tap to app content, including server-side requirements.

Strong Answer

A strong answer covers the user tapping the link, iOS validating the domain against registered Associated Domains for installed apps, downloading/caching the 'apple-app-site-association' (AASA) file from the server for path matching, and finally, either launching the app to the specific content or falling back to Safari. Crucially, it highlights the server-side AASA file's JSON structure (appID, paths), HTTPS requirement, and the app's `AppDelegate`/`SceneDelegate` methods for handling `NSUserActivity` (`webpageURL`).

Interviewers Expect you to understand:
  • Associated Domains capability
  • apple-app-site-association (AASA) file content and hosting requirements (HTTPS, no redirects, correct Content-Type)
  • Path matching logic for AASA
  • `NSUserActivityTypeBrowsingWeb` and `webpageURL`
  • Fallback mechanism to Safari
  • Distinction from Custom URL Schemes
KEY TAKEAWAY

Prioritize Universal Links for external deep linking due to their superior user experience and graceful fallback. Always ensure robust URL parsing and thorough testing of all deep link entry points to guarantee seamless navigation.

Frequently Asked Questions

What is the main difference between Custom URL Schemes and Universal Links?
Custom URL Schemes (e.g., `myapp://`) require your app to be installed and gracefully fail if it's not. Universal Links (e.g., `https://example.com/item/123`) are standard web links that open your app if installed, but fall back to opening the content in Safari if the app isn't present, providing a much smoother user experience.
How do I test deep links in the iOS Simulator?
For both custom URL schemes and Universal Links, you can use the Terminal command `xcrun simctl openurl booted <YOUR_DEEP_LINK_URL>`. For Universal Links, you'll also want to test by opening Safari in the simulator and navigating to your Universal Link URL to verify the app opens correctly or shows the banner.
My Universal Links are opening in Safari instead of my app. What's wrong?
This is a common issue. Check your `apple-app-site-association` (AASA) file for JSON validity, correct `appID` (Team ID.Bundle ID), and correct `paths`. Ensure it's served over HTTPS, without redirects, and with the `Content-Type: application/json` header. Also, confirm the Associated Domains capability is correctly configured in Xcode with `applinks:yourdomain.com`. Sometimes, reinstalling the app helps iOS re-fetch the AASA file.
Can I use both Custom URL Schemes and Universal Links in my app?
Yes, you can use both. It's common to use Universal Links for public-facing content (like links from a website) and custom URL schemes for internal app communication or specific use cases not covered by Universal Links. Universal Links are generally preferred due to their superior user experience.
How can I handle query parameters or complex paths in my deep links?
Always use `URLComponents` to parse URLs. It provides robust methods to access the scheme, host, path components, and query parameters reliably. You can then use `URLComponents.queryItems` to extract specific parameter values by name, and `URL.pathComponents` to break down path segments for routing.
#UIKit#Deep Linking#Universal Links#URL Schemes#iOS Development#App Navigation