Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 038 / 100

Deep links and universal links: best practices explained

Open the app from URLs.

Expona Admin8 min read

Opening the app from URLs

One of the most powerful features in iOS development is the ability to launch your app from a URL and navigate directly to specific content. Whether you're implementing referral links, handling social media shares, or allowing users to bookmark deep sections of your app, understanding deep links and universal links: best practices explained is essential for modern iOS development.

Today we'll walk through both approaches—from simple custom URL schemes to production-grade universal links—and show you how to integrate them seamlessly with SwiftUI's navigation system.

Custom URL schemes for simple cases

Custom URL schemes are the simplest way to get started. They work immediately after you register them, require no server setup, and are ideal for internal testing or single-platform scenarios. However, they lack security and have no verification mechanism, so reserve them for non-critical use cases.

To register a custom scheme, open your Xcode project, select your target, go to the Info tab, and add a URL scheme under "URL Types." Let's say you're building a ride-hailing app and want links like rideapp://ride/12345 to open a specific ride.

First, update your Info.plist by configuring the URL scheme:

swift
// In Info.plist or via Xcode:
// URL Schemes: rideapp
// URL identifier: com.example.rideapp

Next, handle the URL in your SwiftUI app using the onOpenURL modifier at the top level:

swift
@main
struct RideApp: App {
    @State private var deepLinkPath: [String] = []
    
    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $deepLinkPath) {
                ContentView()
                    .navigationDestination(for: String.self) { destination in
                        if destination.starts(with: "ride/") {
                            let rideID = destination.replacingOccurrences(of: "ride/", with: "")
                            RideDetailView(rideID: rideID)
                        }
                    }
            }
            .onOpenURL { url in
                handleDeepLink(url: url)
            }
        }
    }
    
    private func handleDeepLink(url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
              let host = components.host else {
            return
        }
        
        deepLinkPath.append(host)
    }
}

The strength of custom schemes is their simplicity. The weakness is that any app can claim to handle rideapp:// URLs—there's no verification. For public links, you need universal links.

Universal links tie your app to a domain you own. When a user taps a link to that domain (e.g., https://ride.example.com/ride/12345), iOS checks your server for a cryptographically signed file called apple-app-site-association to confirm your app should handle it. This is the production-grade approach.

Create the apple-app-site-association file at the root of your web server (accessible via https://ride.example.com/.well-known/apple-app-site-association):

swift
{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAM_ID.com.example.rideapp",
        "paths": [
          "/ride/*",
          "/profile/*"
        ]
      }
    ]
  }
}

Replace TEAM_ID with your Apple Developer Team ID. The paths array specifies which URL patterns your app claims. You can use wildcards; /ride/* matches any ride detail link.

In Xcode, enable "Associated Domains" under your target's Signing & Capabilities:

swift
// Associated Domains capability:
// applinks:ride.example.com
// webcredentials:ride.example.com (optional, for password autofill)

iOS downloads and caches this file, so changes propagate within hours. For testing, you can use Xcode's simulator to check your configuration without waiting.

The advantage: universal links are secure, verified, and users can share https:// URLs that work seamlessly whether the app is installed or not (falling back to web). The tradeoff: you need a domain you control and a properly configured server.

Handle with onOpenURL

Once the system recognizes your deep link (whether via custom scheme or universal link), onOpenURL fires with the URL. Parse it carefully to extract meaningful data and route the user accurately.

Here's a more robust handler for both custom schemes and universal links:

swift
@main
struct RideApp: App {
    @State private var navigationPath: [DeepLinkDestination] = []
    
    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $navigationPath) {
                ContentView()
                    .navigationDestination(for: DeepLinkDestination.self) { destination in
                        switch destination {
                        case .ride(let rideID):
                            RideDetailView(rideID: rideID)
                        case .profile(let userID):
                            ProfileView(userID: userID)
                        case .settings:
                            SettingsView()
                        }
                    }
            }
            .onOpenURL { url in
                handleDeepLink(url: url)
            }
        }
    }
    
    private func handleDeepLink(url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return
        }
        
        // For universal links: https://ride.example.com/ride/12345
        if let host = components.host, host.contains("ride.example.com") {
            let path = components.path
            if path.starts(with: "/ride/") {
                let rideID = path.replacingOccurrences(of: "/ride/", with: "")
                navigationPath.append(.ride(rideID: rideID))
            } else if path.starts(with: "/profile/") {
                let userID = path.replacingOccurrences(of: "/profile/", with: "")
                navigationPath.append(.profile(userID: userID))
            }
        }
        
        // For custom schemes: rideapp://ride/12345
        if url.scheme == "rideapp" {
            let path = url.host ?? ""
            if path.starts(with: "ride") {
                let rideID = url.lastPathComponent
                navigationPath.append(.ride(rideID: rideID))
            }
        }
    }
}

enum DeepLinkDestination: Hashable {
    case ride(rideID: String)
    case profile(userID: String)
    case settings
}

Key points: always validate and sanitize the URL data before using it. Extract path components carefully. Normalize IDs (trim whitespace, validate format) to prevent crashes or injection attacks. Consider query parameters too—they're useful for tracking attribution or passing optional state.

If your app is already open and a deep link arrives, onOpenURL still fires, so you can update the navigation path dynamically. This is powerful for real-time features like notifications.

Route into NavigationStack paths

The true power emerges when you treat deep links as a first-class navigation concern, building them directly into your NavigationStack path state.

Define a route enum that mirrors your app's structure:

swift
enum AppRoute: Hashable {
    case home
    case ride(id: String)
    case profile(id: String)
    case settings
    case payment(method: String)
}

@main
struct RideApp: App {
    @State private var navigationPath: [AppRoute] = []
    
    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $navigationPath) {
                HomeView()
                    .navigationDestination(for: AppRoute.self) { route in
                        routeView(for: route)
                    }
            }
            .onOpenURL { url in
                parseAndNavigate(url: url)
            }
        }
    }
    
    @ViewBuilder
    private func routeView(for route: AppRoute) -> some View {
        switch route {
        case .home:
            HomeView()
        case .ride(let id):
            RideDetailView(rideID: id)
        case .profile(let id):
            ProfileView(userID: id)
        case .settings:
            SettingsView()
        case .payment(let method):
            PaymentView(method: method)
        }
    }
    
    private func parseAndNavigate(url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return
        }
        
        let pathSegments = components.path
            .split(separator: "/")
            .map(String.init)
        
        guard let firstSegment = pathSegments.first else {
            return
        }
        
        switch firstSegment {
        case "ride":
            if let rideID = pathSegments.dropFirst().first {
                navigationPath.append(.ride(id: rideID))
            }
        case "profile":
            if let userID = pathSegments.dropFirst().first {
                navigationPath.append(.profile(id: userID))
            }
        case "payment":
            if let method = URLComponents(url: url, resolvingAgainstBaseURL: true)?
                .queryItems?.first(where: { $0.name == "method" })?.value {
                navigationPath.append(.payment(method: method))
            }
        default:
            break
        }
    }
}

This approach is clean, testable, and scales. You can unit-test your parsing logic separately from your UI. It also handles the "app already open" scenario naturally—appending to the navigation path updates the UI immediately.

Common mistakes

Not handling the cold start case: If your app isn't running when the deep link is tapped, SwiftUI might not trigger onOpenURL in time during app initialization. Use scenePhase monitoring to catch URLs during launch if needed.

Forgetting to validate the apple-app-site-association file: Typos or incorrect JSON structure will silently fail. Test in Xcode's simulator using the "Open URL" simulation tool or use Apple's official AASA validation tool.

Mixing custom scheme and universal link logic poorly: They coexist, but conflicting handlers can cause confusion. Keep parsing logic separate and test both paths thoroughly.

Not encoding query parameters: If you're passing user-generated content in URLs, always percent-encode them. Use URLComponents instead of manual string concatenation.

Ignoring the navigation state: If your app is deep in the navigation stack and a deep link arrives, consider whether you should reset the stack to the root or push the new route. This depends on UX, but be intentional.

Key takeaways

  • Use custom URL schemes for internal testing or simple single-platform scenarios; they're quick but unverified.
  • Universal links require a domain and apple-app-site-association file but are production-grade, secure, and best practice for public-facing URLs.
  • Always handle deep links in onOpenURL, parse URLs defensively, and validate all extracted data.
  • Treat deep links as navigation state: integrate them into your NavigationStack path to keep routing logic unified and testable.
  • Test both cold-start (app not running) and warm-start (app already open) scenarios to ensure consistent behavior.
  • Monitor your apple-app-site-association file closely; iOS caches it, so propagation takes time in production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles