{"id":427,"date":"2026-03-12T17:00:00","date_gmt":"2026-03-12T22:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=427"},"modified":"2026-03-07T04:33:25","modified_gmt":"2026-03-07T09:33:25","slug":"deep-link-routing-guide","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/deep-link-routing-guide\/","title":{"rendered":"Deep Link Routing: How to Route Users to the Right Screen"},"content":{"rendered":"\n<p>Opening an app is the easy part. The hard part is getting users to the right place once they&#39;re inside.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why Routing Matters (Not Just Opening the App)<\/h2>\n\n\n\n<p>A lot of tutorials stop at &quot;configure your app to handle the URL scheme.&quot; That covers the handoff from the operating system to your app, but nothing about what happens next.<\/p>\n\n\n\n<p>Consider a few scenarios your router will face in production:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A link to <code>\/product\/12345<\/code> arrives while the user is three screens deep inside a settings flow<\/li>\n<li>A link to <code>\/order\/history<\/code> arrives but the user is not logged in<\/li>\n<li>A link to <code>\/promo\/SUMMER2026<\/code> contains a campaign parameter that needs to be tracked before navigating<\/li>\n<li>A link arrives for a route that no longer exists after a recent app update<\/li>\n<li>A link arrives with a tampered or malicious payload that should be rejected (see our guide on <a href=\"https:\/\/tolinku.com\/blog\/deep-linking-security\/\">deep linking security<\/a> for more on this)<\/li>\n<\/ul>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>The goal of a proper routing layer is to:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Parse the incoming URL into a structured intent<\/li>\n<li>Decide whether the user has the necessary state (authentication, onboarding completion, etc.) to fulfill that intent<\/li>\n<li>Navigate to the correct screen, adjusting the navigation stack as needed<\/li>\n<li>Fall back gracefully when fulfillment is not possible<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">Designing a URL Structure Worth Routing Against<\/h2>\n\n\n\n<p>Before writing any router code, your URL structure needs to be consistent and predictable. A few rules that pay off over time:<\/p>\n\n\n\n<p><strong>Keep resource paths shallow.<\/strong> <code>\/product\/12345<\/code> is easier to match and extend than <code>\/catalog\/products\/detail\/12345<\/code>. Nesting beyond two levels usually signals that you&#39;re encoding too much state in the URL.<\/p>\n\n\n\n<p><strong>Separate resource identifiers from query parameters.<\/strong> Resource identity belongs in the path (<code>\/order\/abc123<\/code>). Filters, campaign tokens, and display hints belong in query parameters (<code>?ref=email&amp;highlight=true<\/code>). This keeps your route definitions clean and makes parameter extraction predictable. For a detailed look at parameter naming conventions and best practices, see our <a href=\"https:\/\/tolinku.com\/blog\/deep-link-parameters\/\">deep link parameters guide<\/a>.<\/p>\n\n\n\n<p><strong>Use consistent ID formats.<\/strong> 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.<\/p>\n\n\n\n<p><strong>Version intentionally.<\/strong> You don&#39;t need <code>\/v1\/product\/...<\/code> 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&#39;ve distributed links.<\/p>\n\n\n\n<p>A solid URL structure for a typical e-commerce app might look like this:<\/p>\n\n\n\n<pre><code>myapp:\/\/                          \u2192 Home\nmyapp:\/\/product\/{id}              \u2192 Product detail\nmyapp:\/\/category\/{slug}           \u2192 Category listing\nmyapp:\/\/cart                      \u2192 Cart\nmyapp:\/\/order\/{id}                \u2192 Order detail\nmyapp:\/\/account\/profile           \u2192 User profile\nmyapp:\/\/account\/orders            \u2192 Order history\nmyapp:\/\/promo\/{code}              \u2192 Promo code redemption\n<\/code><\/pre>\n\n\n\n<p>For Universal Links and App Links, the same paths apply under your HTTPS domain:<\/p>\n\n\n\n<pre><code>https:\/\/myapp.com\/product\/{id}\nhttps:\/\/myapp.com\/category\/{slug}\n<\/code><\/pre>\n\n\n\n<p>Tolinku&#39;s <a href=\"https:\/\/tolinku.com\/docs\/user-guide\/routes\/\">route configuration<\/a> lets you define these paths centrally and map them to behavior across platforms, which keeps your URL structure consistent even as your app evolves.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Building a Router: Pattern Matching and Parameter Extraction<\/h2>\n\n\n\n<p>A router at its core does two things: match a URL against a set of known patterns, and extract parameters from the match.<\/p>\n\n\n\n<p>Here is a minimal router in Swift that demonstrates both:<\/p>\n\n\n\n<pre><code class=\"language-swift\">struct Route {\n    let pattern: String\n    let handler: ([String: String], URLComponents) -&gt; Void\n\n    func matches(_ path: String) -&gt; [String: String]? {\n        let patternParts = pattern.split(separator: &quot;\/&quot;)\n        let pathParts = path.split(separator: &quot;\/&quot;)\n\n        guard patternParts.count == pathParts.count else { return nil }\n\n        var params: [String: String] = [:]\n        for (patternPart, pathPart) in zip(patternParts, pathParts) {\n            if patternPart.hasPrefix(&quot;:&quot;) {\n                let key = String(patternPart.dropFirst())\n                params[key] = String(pathPart)\n            } else if patternPart != pathPart {\n                return nil\n            }\n        }\n        return params\n    }\n}\n\nclass AppRouter {\n    private var routes: [Route] = []\n\n    func register(pattern: String, handler: @escaping ([String: String], URLComponents) -&gt; Void) {\n        routes.append(Route(pattern: pattern, handler: handler))\n    }\n\n    func handle(url: URL) -&gt; Bool {\n        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),\n              let path = components.path.isEmpty ? &quot;\/&quot; : Optional(components.path) else {\n            return false\n        }\n\n        for route in routes {\n            if let params = route.matches(path) {\n                route.handler(params, components)\n                return true\n            }\n        }\n        return false\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>And the equivalent in Kotlin for Android:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">data class Route(\n    val pattern: String,\n    val handler: (Map&lt;String, String&gt;, Uri) -&gt; Unit\n) {\n    fun matches(path: String): Map&lt;String, String&gt;? {\n        val patternParts = pattern.trim(&#39;\/&#39;).split(&quot;\/&quot;)\n        val pathParts = path.trim(&#39;\/&#39;).split(&quot;\/&quot;)\n\n        if (patternParts.size != pathParts.size) return null\n\n        val params = mutableMapOf&lt;String, String&gt;()\n        for ((patternPart, pathPart) in patternParts.zip(pathParts)) {\n            when {\n                patternPart.startsWith(&quot;:&quot;) -&gt; params[patternPart.drop(1)] = pathPart\n                patternPart != pathPart -&gt; return null\n            }\n        }\n        return params\n    }\n}\n\nclass AppRouter {\n    private val routes = mutableListOf&lt;Route&gt;()\n\n    fun register(pattern: String, handler: (Map&lt;String, String&gt;, Uri) -&gt; Unit) {\n        routes.add(Route(pattern, handler))\n    }\n\n    fun handle(uri: Uri): Boolean {\n        val path = uri.path ?: return false\n        for (route in routes) {\n            val params = route.matches(path) ?: continue\n            route.handler(params, uri)\n            return true\n        }\n        return false\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>The pattern <code>:id<\/code> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">iOS Routing: AppDelegate, SceneDelegate, and the Coordinator Pattern<\/h2>\n\n\n\n<p>On iOS, deep links arrive in one of two places depending on how the user&#39;s device opened the URL:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>application(_:open:options:)<\/code> in <code>AppDelegate<\/code> for custom URL schemes<\/li>\n<li><code>scene(_:continue:)<\/code> in <code>SceneDelegate<\/code> for Universal Links arriving via <code>NSUserActivity<\/code><\/li>\n<\/ul>\n\n\n\n<p>Both should funnel into the same router:<\/p>\n\n\n\n<pre><code class=\"language-swift\">\/\/ AppDelegate.swift\nfunc application(_ app: UIApplication, open url: URL,\n                 options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -&gt; Bool {\n    return AppRouter.shared.handle(url: url)\n}\n\n\/\/ SceneDelegate.swift\nfunc scene(_ scene: UIScene, continue userActivity: NSUserActivity) {\n    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,\n          let url = userActivity.webpageURL else { return }\n    AppRouter.shared.handle(url: url)\n}\n\nfunc scene(_ scene: UIScene, openURLContexts URLContexts: Set&lt;UIOpenURLContext&gt;) {\n    guard let url = URLContexts.first?.url else { return }\n    AppRouter.shared.handle(url: url)\n}\n<\/code><\/pre>\n\n\n\n<p>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.<\/p>\n\n\n\n<pre><code class=\"language-swift\">class ProductCoordinator {\n    let navigationController: UINavigationController\n\n    init(navigationController: UINavigationController) {\n        self.navigationController = navigationController\n    }\n\n    func showProduct(id: String) {\n        let vc = ProductViewController(productId: id)\n        navigationController.pushViewController(vc, animated: true)\n    }\n}\n\n\/\/ Register routes in your app setup\nAppRouter.shared.register(pattern: &quot;\/product\/:id&quot;) { params, components in\n    guard let id = params[&quot;id&quot;] else { return }\n    AppCoordinator.shared.showProduct(id: id)\n}\n<\/code><\/pre>\n\n\n\n<p>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:<\/p>\n\n\n\n<pre><code class=\"language-swift\">class AppRouter {\n    var pendingURL: URL?\n\n    func handle(url: URL) -&gt; Bool {\n        guard appIsReady else {\n            pendingURL = url\n            return true\n        }\n        return route(url: url)\n    }\n\n    func appDidBecomeReady() {\n        if let url = pendingURL {\n            pendingURL = nil\n            _ = route(url: url)\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Android Routing: Navigation Component and Deep Link Destinations<\/h2>\n\n\n\n<p>Android&#39;s <a href=\"https:\/\/developer.android.com\/guide\/navigation\" rel=\"nofollow noopener\" target=\"_blank\">Navigation component<\/a> has built-in deep link support through <code>&lt;deepLink&gt;<\/code> declarations in your navigation graph. For simple cases this handles routing for you. For more control, you can intercept the intent directly.<\/p>\n\n\n\n<p>Declare deep link destinations in your nav graph XML:<\/p>\n\n\n\n<pre><code class=\"language-xml\">&lt;fragment\n    android:id=&quot;@+id\/productFragment&quot;\n    android:name=&quot;com.myapp.ProductFragment&quot;\n    tools:layout=&quot;@layout\/fragment_product&quot;&gt;\n    &lt;argument\n        android:name=&quot;productId&quot;\n        app:argType=&quot;string&quot; \/&gt;\n    &lt;deepLink\n        app:uri=&quot;myapp:\/\/product\/{productId}&quot;\n        app:action=&quot;android.intent.action.VIEW&quot; \/&gt;\n&lt;\/fragment&gt;\n<\/code><\/pre>\n\n\n\n<p>For Universal Links (App Links on Android), add the corresponding HTTPS URI:<\/p>\n\n\n\n<pre><code class=\"language-xml\">&lt;deepLink\n    app:uri=&quot;https:\/\/myapp.com\/product\/{productId}&quot; \/&gt;\n<\/code><\/pre>\n\n\n\n<p>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:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">class MainActivity : AppCompatActivity() {\n\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n        setContentView(R.layout.activity_main)\n\n        if (savedInstanceState == null) {\n            intent?.data?.let { uri -&gt;\n                if (!AppRouter.handle(uri, this)) {\n                    navigateToHome()\n                }\n            }\n        }\n    }\n\n    override fun onNewIntent(intent: Intent?) {\n        super.onNewIntent(intent)\n        intent?.data?.let { uri -&gt;\n            AppRouter.handle(uri, this)\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p>Register routes once during application initialization:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">class MyApplication : Application() {\n    override fun onCreate() {\n        super.onCreate()\n\n        AppRouter.register(&quot;\/product\/:id&quot;) { params, uri -&gt;\n            val id = params[&quot;id&quot;] ?: return@register\n            val intent = ProductActivity.newIntent(this, id)\n            startActivity(intent)\n        }\n\n        AppRouter.register(&quot;\/account\/orders&quot;) { _, _ -&gt;\n            if (AuthManager.isLoggedIn()) {\n                startActivity(OrderHistoryActivity.newIntent(this))\n            } else {\n                startActivity(LoginActivity.newIntentWithRedirect(this, &quot;\/account\/orders&quot;))\n            }\n        }\n    }\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Unknown Routes Gracefully<\/h2>\n\n\n\n<p>Every router will eventually receive a URL it does not recognize. This happens when:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A user taps an old link after you have restructured your URL paths<\/li>\n<li>A link is constructed incorrectly by a third-party integration<\/li>\n<li>A route that existed in a previous version of the app is no longer present<\/li>\n<\/ul>\n\n\n\n<p>The worst response is a silent failure that lands the user on the home screen with no feedback. Our <a href=\"https:\/\/tolinku.com\/blog\/deep-link-fallback-behavior\/\">deep link fallback behavior guide<\/a> covers the full range of fallback strategies in detail. Better options, in order of preference:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><p><strong>Redirect to the closest equivalent.<\/strong> Keep a mapping of deprecated paths to their replacements inside your router. A <code>\/v1\/product\/:id<\/code> to <code>\/product\/:id<\/code> redirect table adds minimal maintenance burden and handles a lot of real-world cases.<\/p>\n\n\n\n<p><strong>Fall through to web content.<\/strong> 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.<\/p>\n\n\n\n<p><strong>Show a contextual error screen.<\/strong> 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).<\/p>\n\n\n\n<pre><code class=\"language-swift\">\/\/ Swift: fallback handler registered last\nAppRouter.shared.registerFallback { url in\n    if let webURL = url.asWebURL {\n        \/\/ open in SFSafariViewController\n        presentSafariViewController(url: webURL)\n    } else {\n        navigateToHome()\n        showToast(&quot;That link is no longer available.&quot;)\n    }\n}\n<\/code><\/pre>\n\n\n\n<pre><code class=\"language-kotlin\">\/\/ Kotlin: fallback in AppRouter.handle\nfun handle(uri: Uri, context: Context): Boolean {\n    val path = uri.path ?: return false\n    for (route in routes) {\n        val params = route.matches(path) ?: continue\n        route.handler(params, uri)\n        return true\n    }\n    \/\/ Fallback: try web\n    return tryOpenInBrowser(uri, context)\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Testing Your Router<\/h2>\n\n\n\n<p>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 <a href=\"https:\/\/tolinku.com\/blog\/deep-link-testing-tools\/\">deep link testing tools<\/a> article. A few practices that catch problems early:<\/p>\n\n\n\n<p><strong>Unit test pattern matching directly.<\/strong> 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.<\/p>\n\n\n\n<pre><code class=\"language-swift\">func testProductRouteMatches() {\n    let route = Route(pattern: &quot;\/product\/:id&quot;) { _, _ in }\n    let params = route.matches(&quot;\/product\/abc123&quot;)\n    XCTAssertEqual(params?[&quot;id&quot;], &quot;abc123&quot;)\n}\n\nfunc testProductRouteDoesNotMatchCategory() {\n    let route = Route(pattern: &quot;\/product\/:id&quot;) { _, _ in }\n    XCTAssertNil(route.matches(&quot;\/category\/shoes&quot;))\n}\n<\/code><\/pre>\n\n\n\n<p><strong>Test the full URL handling path.<\/strong> Pass complete URLs (including scheme, host, and query parameters) through the top-level <code>handle<\/code> method to confirm that parsing and dispatch work end-to-end.<\/p>\n\n\n\n<p><strong>Test cold launch routing.<\/strong> 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.<\/p>\n\n\n\n<p><strong>Test authentication guards.<\/strong> Confirm that routes requiring login redirect to login (with the intended destination preserved) rather than crashing or showing blank screens.<\/p>\n\n\n\n<p>Tolinku&#39;s <a href=\"https:\/\/tolinku.com\/features\/deep-linking\">deep linking features<\/a> 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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Putting It Together<\/h2>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>For a broader look at how deep links work across platforms and link types, the <a href=\"https:\/\/tolinku.com\/docs\/concepts\/deep-linking\/\">deep linking concepts overview<\/a> is a good place to start before diving into the routing layer.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Learn how to implement deep link routing in your mobile app. Route users to specific screens, handle parameters, and manage fallback behavior.<\/p>\n","protected":false},"author":2,"featured_media":426,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Deep Link Routing: How to Route Users to the Right Screen","rank_math_description":"Learn how to implement deep link routing in your mobile app. Route users to specific screens, handle parameters, and manage fallback behavior.","rank_math_focus_keyword":"deep link routing","rank_math_canonical_url":"","rank_math_facebook_title":"","rank_math_facebook_description":"","rank_math_facebook_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-deep-link-routing-guide.png","rank_math_facebook_image_id":"","rank_math_twitter_title":"","rank_math_twitter_description":"","rank_math_twitter_image":"https:\/\/tolinku.com\/blog\/wp-content\/uploads\/2026\/03\/og-deep-link-routing-guide.png","footnotes":""},"categories":[11],"tags":[25,20,24,34,31],"class_list":["post-427","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-deep-linking","tag-android","tag-deep-linking","tag-ios","tag-kotlin","tag-swift"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/427","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/comments?post=427"}],"version-history":[{"count":2,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/427\/revisions"}],"predecessor-version":[{"id":2767,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/427\/revisions\/2767"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/426"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=427"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=427"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=427"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}