{"id":1879,"date":"2026-07-29T09:00:00","date_gmt":"2026-07-29T14:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=1879"},"modified":"2026-03-07T03:50:16","modified_gmt":"2026-03-07T08:50:16","slug":"cross-platform-link-handling","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/cross-platform-link-handling\/","title":{"rendered":"Cross-Platform Link Handling Patterns"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Deep link handling requires platform-specific entry points (Universal Links on iOS, App Links on Android, URL routing on web) but the actual routing logic, the part that decides &quot;this URL maps to this screen with these parameters,&quot; can be shared. This article covers patterns for writing link handling code once and adapting it per platform.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For the complete cross-platform overview, see <a href=\"https:\/\/tolinku.com\/blog\/cross-platform-deep-linking-guide\/\">cross-platform deep linking guide for 2026<\/a>. For routing fundamentals, see <a href=\"https:\/\/tolinku.com\/blog\/deep-link-routing-guide\/\">deep link routing: how to route users to the right screen<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Problem<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Each platform has its own API for receiving deep links:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>iOS<\/strong>: <code>application(_:continue:restorationHandler:)<\/code> in AppDelegate or SceneDelegate<\/li>\n<li><strong>Android<\/strong>: Intent with <code>ACTION_VIEW<\/code> delivered to the Activity<\/li>\n<li><strong>Web<\/strong>: <code>window.location<\/code> parsed on page load<\/li>\n<li><strong>Capacitor\/Ionic<\/strong>: <code>App.addListener(&#39;appUrlOpen&#39;, ...)<\/code><\/li>\n<li><strong>React Native<\/strong>: <code>Linking.addEventListener(&#39;url&#39;, ...)<\/code><\/li>\n<li><strong>Flutter<\/strong>: <code>uni_links<\/code> or <code>app_links<\/code> package<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The URL arrives through different APIs, but the question is always the same: given this path and these query parameters, which screen should the user see?<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pattern 1: Shared Route Table<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Define routes in a platform-agnostic format. Each platform&#39;s entry point parses the URL and passes it through the shared table.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Route Definition<\/h3>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ routes.ts (shared across all platforms)\ninterface RouteMatch {\n  screen: string;\n  params: Record&lt;string, string&gt;;\n}\n\ninterface RoutePattern {\n  pattern: RegExp;\n  screen: string;\n  paramNames: string[];\n}\n\nconst routes: RoutePattern[] = [\n  {\n    pattern: \/^\\\/products\\\/([^\/]+)$\/,\n    screen: &#39;ProductDetail&#39;,\n    paramNames: [&#39;productId&#39;]\n  },\n  {\n    pattern: \/^\\\/offers\\\/([^\/]+)$\/,\n    screen: &#39;OfferDetail&#39;,\n    paramNames: [&#39;offerId&#39;]\n  },\n  {\n    pattern: \/^\\\/referral\\\/([^\/]+)$\/,\n    screen: &#39;Referral&#39;,\n    paramNames: [&#39;referrerId&#39;]\n  },\n  {\n    pattern: \/^\\\/categories\\\/([^\/]+)\\\/products$\/,\n    screen: &#39;CategoryProducts&#39;,\n    paramNames: [&#39;categoryId&#39;]\n  },\n  {\n    pattern: \/^\\\/search$\/,\n    screen: &#39;Search&#39;,\n    paramNames: []\n  }\n];\n\nexport function matchRoute(urlString: string): RouteMatch | null {\n  const url = new URL(urlString);\n  const path = url.pathname;\n\n  for (const route of routes) {\n    const match = path.match(route.pattern);\n    if (match) {\n      const params: Record&lt;string, string&gt; = {};\n      route.paramNames.forEach((name, i) =&gt; {\n        params[name] = match[i + 1];\n      });\n\n      \/\/ Include query parameters\n      url.searchParams.forEach((value, key) =&gt; {\n        params[key] = value;\n      });\n\n      return { screen: route.screen, params };\n    }\n  }\n\n  return null;\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Platform-Specific Entry Points<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>iOS (Swift)<\/strong>:<\/p>\n\n\n\n<pre><code class=\"language-swift\">func application(_ application: UIApplication,\n                 continue userActivity: NSUserActivity,\n                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -&gt; Void) -&gt; Bool {\n    guard let url = userActivity.webPageUrl else { return false }\n\n    \/\/ Call shared routing logic (via JavaScript bridge, or re-implement in Swift)\n    let route = DeepLinkRouter.match(url: url)\n    if let route = route {\n        navigate(to: route.screen, params: route.params)\n        return true\n    }\n    return false\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Android (Kotlin)<\/strong>:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">override fun onCreate(savedInstanceState: Bundle?) {\n    super.onCreate(savedInstanceState)\n    intent?.data?.let { uri -&gt;\n        val route = DeepLinkRouter.match(uri.toString())\n        route?.let { navigateTo(it.screen, it.params) }\n    }\n}\n\noverride fun onNewIntent(intent: Intent) {\n    super.onNewIntent(intent)\n    intent.data?.let { uri -&gt;\n        val route = DeepLinkRouter.match(uri.toString())\n        route?.let { navigateTo(it.screen, it.params) }\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Web<\/strong>:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ On page load or route change\nconst route = matchRoute(window.location.href);\nif (route) {\n  renderScreen(route.screen, route.params);\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Pattern 2: URL-to-Action Mapping<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Instead of mapping URLs directly to screens, map them to actions. This decouples the deep link layer from the navigation layer.<\/p>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ actions.ts\ntype DeepLinkAction =\n  | { type: &#39;VIEW_PRODUCT&#39;; productId: string; source?: string }\n  | { type: &#39;VIEW_OFFER&#39;; offerId: string }\n  | { type: &#39;APPLY_REFERRAL&#39;; referrerId: string }\n  | { type: &#39;SEARCH&#39;; query: string }\n  | { type: &#39;OPEN_HOME&#39; };\n\nexport function urlToAction(urlString: string): DeepLinkAction {\n  const url = new URL(urlString);\n  const path = url.pathname;\n  const query = url.searchParams;\n\n  const productMatch = path.match(\/^\\\/products\\\/([^\/]+)$\/);\n  if (productMatch) {\n    return {\n      type: &#39;VIEW_PRODUCT&#39;,\n      productId: productMatch[1],\n      source: query.get(&#39;ref&#39;) ?? undefined\n    };\n  }\n\n  const offerMatch = path.match(\/^\\\/offers\\\/([^\/]+)$\/);\n  if (offerMatch) {\n    return { type: &#39;VIEW_OFFER&#39;, offerId: offerMatch[1] };\n  }\n\n  const referralMatch = path.match(\/^\\\/referral\\\/([^\/]+)$\/);\n  if (referralMatch) {\n    return { type: &#39;APPLY_REFERRAL&#39;, referrerId: referralMatch[1] };\n  }\n\n  if (path === &#39;\/search&#39;) {\n    return { type: &#39;SEARCH&#39;, query: query.get(&#39;q&#39;) ?? &#39;&#39; };\n  }\n\n  return { type: &#39;OPEN_HOME&#39; };\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each platform implements its own action handler:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ iOS, Android, web each implement this interface\ninterface ActionHandler {\n  handleAction(action: DeepLinkAction): void;\n}\n\n\/\/ Example: React Native handler\nclass RNActionHandler implements ActionHandler {\n  handleAction(action: DeepLinkAction) {\n    switch (action.type) {\n      case &#39;VIEW_PRODUCT&#39;:\n        navigation.navigate(&#39;ProductDetail&#39;, {\n          productId: action.productId\n        });\n        break;\n      case &#39;APPLY_REFERRAL&#39;:\n        \/\/ Store referral, then navigate\n        ReferralStore.save(action.referrerId);\n        navigation.navigate(&#39;Home&#39;);\n        break;\n      \/\/ ...\n    }\n  }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This pattern is useful when some actions require side effects (storing a referral code, triggering an analytics event) before navigation.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pattern 3: Configuration-Driven Routes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">For apps with many deep link paths, define routes in a configuration file that all platforms read:<\/p>\n\n\n\n<pre><code class=\"language-json\">{\n  &quot;routes&quot;: [\n    {\n      &quot;path&quot;: &quot;\/products\/:productId&quot;,\n      &quot;screen&quot;: &quot;ProductDetail&quot;,\n      &quot;requiresAuth&quot;: false\n    },\n    {\n      &quot;path&quot;: &quot;\/account\/settings&quot;,\n      &quot;screen&quot;: &quot;AccountSettings&quot;,\n      &quot;requiresAuth&quot;: true\n    },\n    {\n      &quot;path&quot;: &quot;\/offers\/:offerId&quot;,\n      &quot;screen&quot;: &quot;OfferDetail&quot;,\n      &quot;requiresAuth&quot;: false\n    },\n    {\n      &quot;path&quot;: &quot;\/checkout\/:cartId&quot;,\n      &quot;screen&quot;: &quot;Checkout&quot;,\n      &quot;requiresAuth&quot;: true\n    }\n  ]\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A shared parser converts path patterns to regex:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">function pathToRegex(path: string): { regex: RegExp; paramNames: string[] } {\n  const paramNames: string[] = [];\n  const regexStr = path.replace(\/:([^\/]+)\/g, (_, name) =&gt; {\n    paramNames.push(name);\n    return &#39;([^\/]+)&#39;;\n  });\n  return {\n    regex: new RegExp(`^${regexStr}$`),\n    paramNames\n  };\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This approach keeps route definitions in sync across platforms. If you add a new deep link path, update the config file once.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Handling Platform Differences<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Even with shared routing logic, some behaviors differ between platforms.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Authentication Gates<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When a deep link targets an authenticated screen, the behavior depends on platform conventions:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">function handleAuthenticatedRoute(\n  route: RouteMatch,\n  isLoggedIn: boolean,\n  platform: &#39;ios&#39; | &#39;android&#39; | &#39;web&#39;\n) {\n  if (isLoggedIn) {\n    navigate(route.screen, route.params);\n    return;\n  }\n\n  \/\/ Save the intended destination\n  saveDeepLinkDestination(route);\n\n  \/\/ Platform-specific login flow\n  if (platform === &#39;web&#39;) {\n    \/\/ Redirect to login page with return URL\n    window.location.href = `\/login?returnTo=${encodeURIComponent(route.screen)}`;\n  } else {\n    \/\/ Show native login screen, then navigate after success\n    showLoginScreen();\n  }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Fallback Behavior<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When a route does not match, each platform has a different fallback:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table>\n<thead>\n<tr>\n<th>Platform<\/th>\n<th>Fallback<\/th>\n<\/tr>\n<\/thead>\n<tbody><tr>\n<td>iOS<\/td>\n<td>Open home screen or show error<\/td>\n<\/tr>\n<tr>\n<td>Android<\/td>\n<td>Open home screen or let system handle (browser)<\/td>\n<\/tr>\n<tr>\n<td>Web<\/td>\n<td>Show 404 page or redirect to home<\/td>\n<\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<pre><code class=\"language-typescript\">function handleUnmatchedUrl(url: string, platform: string) {\n  if (platform === &#39;web&#39;) {\n    \/\/ Web can show a 404 page\n    router.navigate(&#39;\/not-found&#39;);\n  } else {\n    \/\/ Mobile apps should gracefully degrade to home\n    router.navigate(&#39;\/&#39;);\n  }\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Keeping Routes in Sync<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The hardest part of cross-platform link handling is keeping routes synchronized across:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>AASA file<\/strong> (iOS path patterns)<\/li>\n<li><strong>assetlinks.json<\/strong> (Android verification)<\/li>\n<li><strong>AndroidManifest.xml<\/strong> (Intent filter path prefixes)<\/li>\n<li><strong>App router<\/strong> (client-side route definitions)<\/li>\n<li><strong>Backend<\/strong> (server-side route handling for web)<\/li>\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\">Single Source of Truth<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Generate platform-specific configuration from a single route definition:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ generate-configs.ts\nimport routeConfig from &#39;.\/routes.json&#39;;\n\n\/\/ Generate AASA components\nfunction generateAASA(routes: Route[]) {\n  return {\n    applinks: {\n      details: [{\n        appIDs: [&#39;TEAMID.com.yourcompany.yourapp&#39;],\n        components: routes.map(r =&gt; ({\n          &#39;\/&#39;: r.path.replace(\/:([^\/]+)\/g, &#39;*&#39;)\n        }))\n      }]\n    }\n  };\n}\n\n\/\/ Generate Android Intent filter paths\nfunction generateIntentFilters(routes: Route[]) {\n  \/\/ Extract unique path prefixes\n  const prefixes = new Set(\n    routes.map(r =&gt; &#39;\/&#39; + r.path.split(&#39;\/&#39;)[1])\n  );\n  return Array.from(prefixes);\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This prevents the common bug where a new route works on one platform but not another because someone forgot to update the AASA or Intent filters.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Testing Cross-Platform Routes<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Test the shared routing logic with platform-specific test URLs:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">describe(&#39;matchRoute&#39;, () =&gt; {\n  const testCases = [\n    {\n      url: &#39;https:\/\/yourdomain.com\/products\/abc123&#39;,\n      expected: { screen: &#39;ProductDetail&#39;, params: { productId: &#39;abc123&#39; } }\n    },\n    {\n      url: &#39;https:\/\/yourdomain.com\/products\/abc123?ref=email&amp;campaign=summer&#39;,\n      expected: {\n        screen: &#39;ProductDetail&#39;,\n        params: { productId: &#39;abc123&#39;, ref: &#39;email&#39;, campaign: &#39;summer&#39; }\n      }\n    },\n    {\n      url: &#39;https:\/\/yourdomain.com\/unknown\/path&#39;,\n      expected: null\n    }\n  ];\n\n  testCases.forEach(({ url, expected }) =&gt; {\n    it(`matches ${url}`, () =&gt; {\n      expect(matchRoute(url)).toEqual(expected);\n    });\n  });\n});\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Tolinku for Cross-Platform Apps<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/tolinku.com\/features\/deep-linking\">Tolinku<\/a> handles AASA and assetlinks.json hosting, so your verification files stay in sync automatically. The platform also provides <a href=\"https:\/\/tolinku.com\/docs\/concepts\/deep-linking\/\">deferred deep linking<\/a> for users who need to install the app first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For hybrid app challenges, see <a href=\"https:\/\/tolinku.com\/blog\/deep-linking-hybrid-apps\/\">deep linking in hybrid apps: challenges and solutions<\/a>. For the complete guide, see <a href=\"https:\/\/tolinku.com\/blog\/cross-platform-deep-linking-guide\/\">cross-platform deep linking guide for 2026<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Design link handling that works across iOS, Android, and web. Share routing logic, handle platform differences, and maintain consistency.<\/p>\n","protected":false},"author":2,"featured_media":1878,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Cross-Platform Link Handling Patterns","rank_math_description":"Design link handling that works across iOS, Android, and web. Share routing logic, handle platform differences, and maintain consistency.","rank_math_focus_keyword":"cross-platform link handling","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-cross-platform-link-handling.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-cross-platform-link-handling.png","footnotes":""},"categories":[15],"tags":[25,23,305,156,20,24,69,183,290,22],"class_list":["post-1879","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-engineering","tag-android","tag-app-links","tag-architecture","tag-cross-platform","tag-deep-linking","tag-ios","tag-mobile-development","tag-routing","tag-typescript","tag-universal-links"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1879","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=1879"}],"version-history":[{"count":1,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1879\/revisions"}],"predecessor-version":[{"id":1880,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1879\/revisions\/1880"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/1878"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=1879"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=1879"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=1879"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}