BS
BleepingSwift
Published on
7 min read

> Deep Linking and Universal Links Setup in iOS

Share:

Deep linking lets users tap a link and land directly on a specific screen in your app rather than the home screen. iOS supports two flavors: custom URL schemes (like myapp://profile/settings) and Universal Links (like https://example.com/profile/settings). Both ultimately deliver a URL to your app, but they work differently under the hood and each has trade-offs worth understanding.

Custom URL Schemes

Custom URL schemes are the simpler option. You register a scheme like myapp:// with iOS, and any URL matching that scheme gets routed to your app. Setup happens entirely in Xcode with no server configuration required.

To register a scheme, open your project's Info tab and add an entry under URL Types. Set the URL Schemes field to your app's scheme (just the prefix, without ://). You can also do this directly in your Info.plist:

XML
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>myapp</string>
        </array>
        <key>CFBundleURLName</key>
        <string>com.yourcompany.myapp</string>
    </dict>
</array>

Once registered, URLs like myapp://products/123 or myapp://settings will open your app. In SwiftUI, you handle them with the .onOpenURL modifier:

Swift
import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    handleDeepLink(url)
                }
        }
    }

    func handleDeepLink(_ url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
        let path = components.path
        let queryItems = components.queryItems

        print("Scheme: \(components.scheme ?? "")")
        print("Host: \(components.host ?? "")")
        print("Path: \(path)")
        print("Query: \(queryItems ?? [])")
    }
}

For a URL like myapp://products/123?ref=notification, you get host = "products", path = "/123", and a query item with ref=notification.

The main limitation of custom URL schemes is that there's no ownership verification. Any app can register the same scheme, and iOS doesn't guarantee which app handles it. This is why Apple recommends Universal Links for production apps.

Universal Links use standard https:// URLs. When a user taps a link to your domain and you've set up the association correctly, iOS opens your app instead of Safari. If your app isn't installed, the link falls through to the web as normal. This is a much better user experience than custom schemes, which just fail if the app isn't installed.

Setting up Universal Links requires coordination between your app and your web server.

Server-Side Configuration

Create an apple-app-site-association (AASA) file and host it at https://yourdomain.com/.well-known/apple-app-site-association. This JSON file tells iOS which URL paths your app handles:

JSON
{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.yourcompany.myapp"],
        "components": [
          {
            "/": "/products/*",
            "comment": "Product detail pages"
          },
          {
            "/": "/profile/*",
            "comment": "User profile pages"
          },
          {
            "/": "/invite/*",
            "comment": "Invite links"
          }
        ]
      }
    ]
  }
}

Replace TEAMID with your Apple Developer Team ID (found in your Apple Developer account under Membership). The file must be served over HTTPS with a Content-Type of application/json, and it must not require any redirects to access. Apple's CDN fetches this file when your app is installed and caches it, so changes can take time to propagate. You can check the status of your AASA file using Apple's search validation tool.

App-Side Configuration

In Xcode, go to your target's Signing & Capabilities tab and add the Associated Domains capability. Add an entry like:

JavaScript
applinks:yourdomain.com

That's it for the Xcode side. The .onOpenURL modifier you already set up for custom schemes handles Universal Links too. iOS delivers the full https:// URL to the same callback.

Building a URL Router

For anything beyond a trivial app, you'll want a structured way to parse incoming URLs into navigation destinations. Here's one approach:

Swift
import Foundation

enum DeepLinkDestination: Hashable {
    case productDetail(id: String)
    case profile(username: String)
    case settings
    case invite(code: String)
}

struct DeepLinkRouter {
    func destination(for url: URL) -> DeepLinkDestination? {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            return nil
        }

        // Normalize path segments from both URL scheme and universal link formats
        let pathSegments: [String]
        if components.scheme == "myapp" {
            // myapp://products/123 -> host is "products", path is "/123"
            var segments = [components.host].compactMap { $0 }
            segments += components.path
                .split(separator: "/")
                .map(String.init)
            pathSegments = segments
        } else {
            // https://example.com/products/123 -> path is "/products/123"
            pathSegments = components.path
                .split(separator: "/")
                .map(String.init)
        }

        guard let first = pathSegments.first else { return nil }

        switch first {
        case "products":
            guard let id = pathSegments.dropFirst().first else { return nil }
            return .productDetail(id: id)
        case "profile":
            guard let username = pathSegments.dropFirst().first else { return nil }
            return .profile(username: username)
        case "settings":
            return .settings
        case "invite":
            guard let code = pathSegments.dropFirst().first else { return nil }
            return .invite(code: code)
        default:
            return nil
        }
    }
}

Wiring It Up with NavigationStack

The router pairs nicely with NavigationStack's path-based navigation. When a deep link arrives, parse it into a destination and push it onto the navigation path:

Swift
import SwiftUI

struct ContentView: View {
    @State private var navigationPath = NavigationPath()
    private let router = DeepLinkRouter()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            HomeView()
                .navigationDestination(for: DeepLinkDestination.self) { destination in
                    switch destination {
                    case .productDetail(let id):
                        ProductDetailView(productID: id)
                    case .profile(let username):
                        ProfileView(username: username)
                    case .settings:
                        SettingsView()
                    case .invite(let code):
                        InviteView(code: code)
                    }
                }
        }
        .onOpenURL { url in
            if let destination = router.destination(for: url) {
                // Reset to root and push the destination
                navigationPath = NavigationPath()
                navigationPath.append(destination)
            }
        }
    }
}

Resetting the path before appending clears any existing navigation state so the user lands cleanly on the deep-linked screen. If you want to preserve the existing stack and push on top, skip the reset.

Handling URLs in UIKit

If you're using UIKit or need to handle URLs before SwiftUI's view hierarchy is ready, implement the corresponding app delegate method:

Swift
class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        continue userActivity: NSUserActivity,
        restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
    ) -> Bool {
        guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
              let url = userActivity.webpageURL else {
            return false
        }

        // Handle the universal link URL
        return handleDeepLink(url)
    }

    func application(
        _ app: UIApplication,
        open url: URL,
        options: [UIApplication.OpenURLOptionsKey: Any] = [:]
    ) -> Bool {
        // Handle custom URL scheme
        return handleDeepLink(url)
    }

    private func handleDeepLink(_ url: URL) -> Bool {
        let router = DeepLinkRouter()
        guard let destination = router.destination(for: url) else { return false }
        // Navigate to destination using your app's coordinator/router
        NotificationCenter.default.post(
            name: .deepLinkReceived,
            object: nil,
            userInfo: ["destination": destination]
        )
        return true
    }
}

extension Notification.Name {
    static let deepLinkReceived = Notification.Name("deepLinkReceived")
}

Universal Links arrive through continue userActivity, while custom URL schemes arrive through open url. Your routing logic stays the same for both.

You can test both URL types from the command line using xcrun simctl:

Bash
# Test a custom URL scheme
xcrun simctl openurl booted "myapp://products/456"

# Test a Universal Link
xcrun simctl openurl booted "https://yourdomain.com/products/456"

For Universal Links to work on the simulator, the AASA file needs to be publicly accessible and the domain association needs to be verified. During development, you can also use the ?mode=developer query parameter in your associated domain entry (applinks:yourdomain.com?mode=developer) to bypass the CDN cache and pull directly from your server.

In general, start with Universal Links for any user-facing links. They're more secure since only verified domain owners can claim them, they provide a web fallback when your app isn't installed, and they feel more natural to users since the URLs look like regular web links. Reserve custom URL schemes for app-to-app communication or cases where you control both ends of the interaction.

For more on using NavigationStack with programmatic navigation, check out NavigationView vs NavigationStack.

Sample Project

Want to see this code in action? Check out the complete sample project on GitHub:

View on GitHub

The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.