Skip to content
Tolinku
Tolinku
Sign In Start Free
Deep Linking · · 9 min read

Deep Link Routing: How to Route Users to the Right Screen

By Tolinku Staff
|
Tolinku deep linking fundamentals dashboard screenshot for deep linking blog posts

Opening an app is the easy part. The hard part is getting users to the right place once they're inside.

Deep link routing is the layer of logic that sits between receiving an incoming URL and presenting the correct screen to the user. Get it right and users land exactly where they expect. Get it wrong and they end up staring at a home screen with no idea where the content they were looking for went.

This guide walks through the full picture: how to design a URL structure worth routing against, how to build a router that handles real-world complexity, and how to wire everything up on iOS and Android with concrete code examples.

Why Routing Matters (Not Just Opening the App)

A lot of tutorials stop at "configure your app to handle the URL scheme." That covers the handoff from the operating system to your app, but nothing about what happens next.

Consider a few scenarios your router will face in production:

  • A link to /product/12345 arrives while the user is three screens deep inside a settings flow
  • A link to /order/history arrives but the user is not logged in
  • A link to /promo/SUMMER2026 contains a campaign parameter that needs to be tracked before navigating
  • A link arrives for a route that no longer exists after a recent app update
  • A link arrives with a tampered or malicious payload that should be rejected (see our guide on deep linking security for more on this)

Each of these requires deliberate handling. A router that only knows how to match a path and push a view controller will fail on most of them.

The goal of a proper routing layer is to:

  1. Parse the incoming URL into a structured intent
  2. Decide whether the user has the necessary state (authentication, onboarding completion, etc.) to fulfill that intent
  3. Navigate to the correct screen, adjusting the navigation stack as needed
  4. Fall back gracefully when fulfillment is not possible

Designing a URL Structure Worth Routing Against

Before writing any router code, your URL structure needs to be consistent and predictable. A few rules that pay off over time:

Keep resource paths shallow. /product/12345 is easier to match and extend than /catalog/products/detail/12345. Nesting beyond two levels usually signals that you're encoding too much state in the URL.

Separate resource identifiers from query parameters. Resource identity belongs in the path (/order/abc123). Filters, campaign tokens, and display hints belong in query parameters (?ref=email&highlight=true). This keeps your route definitions clean and makes parameter extraction predictable. For a detailed look at parameter naming conventions and best practices, see our deep link parameters guide.

Use consistent ID formats. If some routes use numeric IDs and others use slugs, your router has to handle both. Pick one convention and apply it across the board.

Version intentionally. You don't need /v1/product/... in deep link URLs, but you do need a plan for what happens when a route is deprecated. A redirect mapping table inside your router (old path to new path) is far easier to maintain than communicating URL changes to every channel where you've distributed links.

A solid URL structure for a typical e-commerce app might look like this:

myapp://                          → Home
myapp://product/{id}              → Product detail
myapp://category/{slug}           → Category listing
myapp://cart                      → Cart
myapp://order/{id}                → Order detail
myapp://account/profile           → User profile
myapp://account/orders            → Order history
myapp://promo/{code}              → Promo code redemption

For Universal Links and App Links, the same paths apply under your HTTPS domain:

https://myapp.com/product/{id}
https://myapp.com/category/{slug}

Tolinku's route configuration lets you define these paths centrally and map them to behavior across platforms, which keeps your URL structure consistent even as your app evolves.

Building a Router: Pattern Matching and Parameter Extraction

A router at its core does two things: match a URL against a set of known patterns, and extract parameters from the match.

Here is a minimal router in Swift that demonstrates both:

struct Route {
    let pattern: String
    let handler: ([String: String], URLComponents) -> Void

    func matches(_ path: String) -> [String: String]? {
        let patternParts = pattern.split(separator: "/")
        let pathParts = path.split(separator: "/")

        guard patternParts.count == pathParts.count else { return nil }

        var params: [String: String] = [:]
        for (patternPart, pathPart) in zip(patternParts, pathParts) {
            if patternPart.hasPrefix(":") {
                let key = String(patternPart.dropFirst())
                params[key] = String(pathPart)
            } else if patternPart != pathPart {
                return nil
            }
        }
        return params
    }
}

class AppRouter {
    private var routes: [Route] = []

    func register(pattern: String, handler: @escaping ([String: String], URLComponents) -> Void) {
        routes.append(Route(pattern: pattern, handler: handler))
    }

    func handle(url: URL) -> Bool {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
              let path = components.path.isEmpty ? "/" : Optional(components.path) else {
            return false
        }

        for route in routes {
            if let params = route.matches(path) {
                route.handler(params, components)
                return true
            }
        }
        return false
    }
}

And the equivalent in Kotlin for Android:

data class Route(
    val pattern: String,
    val handler: (Map<String, String>, Uri) -> Unit
) {
    fun matches(path: String): Map<String, String>? {
        val patternParts = pattern.trim('/').split("/")
        val pathParts = path.trim('/').split("/")

        if (patternParts.size != pathParts.size) return null

        val params = mutableMapOf<String, String>()
        for ((patternPart, pathPart) in patternParts.zip(pathParts)) {
            when {
                patternPart.startsWith(":") -> params[patternPart.drop(1)] = pathPart
                patternPart != pathPart -> return null
            }
        }
        return params
    }
}

class AppRouter {
    private val routes = mutableListOf<Route>()

    fun register(pattern: String, handler: (Map<String, String>, Uri) -> Unit) {
        routes.add(Route(pattern, handler))
    }

    fun handle(uri: Uri): Boolean {
        val path = uri.path ?: return false
        for (route in routes) {
            val params = route.matches(path) ?: continue
            route.handler(params, uri)
            return true
        }
        return false
    }
}

The pattern :id extracts the value at that position into a named parameter. Query parameters come from the URL components object and are handled separately, which keeps the matching logic simple.

iOS Routing: AppDelegate, SceneDelegate, and the Coordinator Pattern

On iOS, deep links arrive in one of two places depending on how the user's device opened the URL:

  • application(_:open:options:) in AppDelegate for custom URL schemes
  • scene(_:continue:) in SceneDelegate for Universal Links arriving via NSUserActivity

Both should funnel into the same router:

// AppDelegate.swift
func application(_ app: UIApplication, open url: URL,
                 options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    return AppRouter.shared.handle(url: url)
}

// SceneDelegate.swift
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return }
    AppRouter.shared.handle(url: url)
}

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let url = URLContexts.first?.url else { return }
    AppRouter.shared.handle(url: url)
}

For navigation, the coordinator pattern works well with deep linking because it centralizes navigation decisions away from individual view controllers. Each coordinator knows how to display a particular section of your app and can accept a starting route.

class ProductCoordinator {
    let navigationController: UINavigationController

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    func showProduct(id: String) {
        let vc = ProductViewController(productId: id)
        navigationController.pushViewController(vc, animated: true)
    }
}

// Register routes in your app setup
AppRouter.shared.register(pattern: "/product/:id") { params, components in
    guard let id = params["id"] else { return }
    AppCoordinator.shared.showProduct(id: id)
}

One thing to handle carefully: the link may arrive before the app has finished loading its root view controller, especially on cold launch. Queuing the pending route and fulfilling it after the app finishes initialization avoids race conditions:

class AppRouter {
    var pendingURL: URL?

    func handle(url: URL) -> Bool {
        guard appIsReady else {
            pendingURL = url
            return true
        }
        return route(url: url)
    }

    func appDidBecomeReady() {
        if let url = pendingURL {
            pendingURL = nil
            _ = route(url: url)
        }
    }
}

Android's Navigation component has built-in deep link support through <deepLink> declarations in your navigation graph. For simple cases this handles routing for you. For more control, you can intercept the intent directly.

Declare deep link destinations in your nav graph XML:

<fragment
    android:id="@+id/productFragment"
    android:name="com.myapp.ProductFragment"
    tools:layout="@layout/fragment_product">
    <argument
        android:name="productId"
        app:argType="string" />
    <deepLink
        app:uri="myapp://product/{productId}"
        app:action="android.intent.action.VIEW" />
</fragment>

For Universal Links (App Links on Android), add the corresponding HTTPS URI:

<deepLink
    app:uri="https://myapp.com/product/{productId}" />

When you need custom routing logic (authentication checks, redirect mapping, analytics events on link arrival), handle the incoming intent before the Navigation component processes it:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (savedInstanceState == null) {
            intent?.data?.let { uri ->
                if (!AppRouter.handle(uri, this)) {
                    navigateToHome()
                }
            }
        }
    }

    override fun onNewIntent(intent: Intent?) {
        super.onNewIntent(intent)
        intent?.data?.let { uri ->
            AppRouter.handle(uri, this)
        }
    }
}

Register routes once during application initialization:

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        AppRouter.register("/product/:id") { params, uri ->
            val id = params["id"] ?: return@register
            val intent = ProductActivity.newIntent(this, id)
            startActivity(intent)
        }

        AppRouter.register("/account/orders") { _, _ ->
            if (AuthManager.isLoggedIn()) {
                startActivity(OrderHistoryActivity.newIntent(this))
            } else {
                startActivity(LoginActivity.newIntentWithRedirect(this, "/account/orders"))
            }
        }
    }
}

Handling Unknown Routes Gracefully

Every router will eventually receive a URL it does not recognize. This happens when:

  • A user taps an old link after you have restructured your URL paths
  • A link is constructed incorrectly by a third-party integration
  • A route that existed in a previous version of the app is no longer present

The worst response is a silent failure that lands the user on the home screen with no feedback. Our deep link fallback behavior guide covers the full range of fallback strategies in detail. Better options, in order of preference:

  1. Redirect to the closest equivalent. Keep a mapping of deprecated paths to their replacements inside your router. A /v1/product/:id to /product/:id redirect table adds minimal maintenance burden and handles a lot of real-world cases.

    Fall through to web content. If your app has a web equivalent, open the URL in a browser or in-app browser instead of failing. The user gets the content they wanted, just not inside the app.

    Show a contextual error screen. If you cannot redirect and web fallback is not appropriate, show a screen that acknowledges the failure and gives the user a clear path back into the app (home, search, or the section they were most likely trying to reach).

    // Swift: fallback handler registered last
    AppRouter.shared.registerFallback { url in
        if let webURL = url.asWebURL {
            // open in SFSafariViewController
            presentSafariViewController(url: webURL)
        } else {
            navigateToHome()
            showToast("That link is no longer available.")
        }
    }
    
    // Kotlin: fallback in AppRouter.handle
    fun handle(uri: Uri, context: Context): Boolean {
        val path = uri.path ?: return false
        for (route in routes) {
            val params = route.matches(path) ?: continue
            route.handler(params, uri)
            return true
        }
        // Fallback: try web
        return tryOpenInBrowser(uri, context)
    }
    

    Testing Your Router

    A router that works in development and breaks in production is worse than one with known gaps, because it fails silently in edge cases you did not think to test. For a roundup of tools that can help automate this process, see our deep link testing tools article. A few practices that catch problems early:

    Unit test pattern matching directly. The route matching logic is pure and deterministic, which makes it ideal for unit testing. Write test cases for exact matches, parameterized matches, mismatches on segment count, and mismatches on literal segments.

    func testProductRouteMatches() {
        let route = Route(pattern: "/product/:id") { _, _ in }
        let params = route.matches("/product/abc123")
        XCTAssertEqual(params?["id"], "abc123")
    }
    
    func testProductRouteDoesNotMatchCategory() {
        let route = Route(pattern: "/product/:id") { _, _ in }
        XCTAssertNil(route.matches("/category/shoes"))
    }
    

    Test the full URL handling path. Pass complete URLs (including scheme, host, and query parameters) through the top-level handle method to confirm that parsing and dispatch work end-to-end.

    Test cold launch routing. Simulate the app receiving a deep link on a fresh launch in your UI tests. This is where the pending URL queue gets exercised and where timing bugs surface.

    Test authentication guards. Confirm that routes requiring login redirect to login (with the intended destination preserved) rather than crashing or showing blank screens.

    Tolinku's deep linking features include link testing tools that let you fire test URLs at your app from the dashboard, which is useful for checking routing behavior across app versions without constructing links manually.

    Putting It Together

    Deep link routing is not a solved problem you can configure once and forget. It requires the same attention as any other navigation logic in your app, which means keeping route definitions up to date as the app changes, writing tests that cover edge cases, and having a clear fallback strategy for routes that no longer exist.

    The structural decisions matter more than the implementation details. A clean URL scheme, a single centralized router, and a consistent approach to authentication guards and fallback handling will get you through most of what production traffic throws at your app.

    For a broader look at how deep links work across platforms and link types, the deep linking concepts overview is a good place to start before diving into the routing layer.

Get deep linking tips in your inbox

One email per week. No spam.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.