{"id":1900,"date":"2026-07-31T13:00:00","date_gmt":"2026-07-31T18:00:00","guid":{"rendered":"https:\/\/tolinku.com\/blog\/?p=1900"},"modified":"2026-03-07T03:50:19","modified_gmt":"2026-03-07T08:50:19","slug":"deep-linking-multi-platform-teams","status":"publish","type":"post","link":"https:\/\/tolinku.com\/blog\/deep-linking-multi-platform-teams\/","title":{"rendered":"Deep Linking for Multi-Platform Engineering Teams"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Deep linking touches every platform your app runs on. The AASA file is an iOS concern. The assetlinks.json is an Android concern. The Intent filters are in AndroidManifest.xml. The route handling might be in Swift, Kotlin, TypeScript, and Dart. When these pieces get out of sync, links break. This article covers how multi-platform teams coordinate deep link implementation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For shared routing patterns, see <a href=\"https:\/\/tolinku.com\/blog\/cross-platform-link-handling\/\">cross-platform link handling patterns<\/a>. For the cross-platform overview, see <a href=\"https:\/\/tolinku.com\/blog\/cross-platform-deep-linking-guide\/\">cross-platform deep linking guide for 2026<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Coordination Problem<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A single deep link route (e.g., <code>\/products\/:productId<\/code>) requires changes in up to six places:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>AASA file<\/strong> (hosted on your domain, for iOS)<\/li>\n<li><strong>assetlinks.json<\/strong> (hosted on your domain, for Android)<\/li>\n<li><strong>iOS entitlements<\/strong> (Associated Domains in Xcode)<\/li>\n<li><strong>AndroidManifest.xml<\/strong> (Intent filter with path prefix)<\/li>\n<li><strong>Client-side router<\/strong> (iOS, Android, and\/or cross-platform framework)<\/li>\n<li><strong>Server-side fallback<\/strong> (for users without the app)<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">If the iOS team adds a route to the AASA but the Android team forgets the Intent filter, the link works on iOS but not Android. If the backend team changes the server-side redirect but the mobile teams do not update the router, the app opens but shows the wrong screen.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Single Source of Truth<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Define all deep link routes in one place. Every team reads from this source.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Route Configuration File<\/h3>\n\n\n\n<pre><code class=\"language-yaml\"># deep-links.yaml\nroutes:\n  - path: &quot;\/products\/{productId}&quot;\n    screen: &quot;ProductDetail&quot;\n    platforms: [ios, android, web]\n    params:\n      productId:\n        type: string\n        required: true\n    queryParams:\n      ref:\n        type: string\n        required: false\n      campaign:\n        type: string\n        required: false\n\n  - path: &quot;\/offers\/{offerId}&quot;\n    screen: &quot;OfferDetail&quot;\n    platforms: [ios, android, web]\n    params:\n      offerId:\n        type: string\n        required: true\n\n  - path: &quot;\/referral\/{referrerId}&quot;\n    screen: &quot;Referral&quot;\n    platforms: [ios, android, web]\n    params:\n      referrerId:\n        type: string\n        required: true\n\n  - path: &quot;\/ar-preview\/{productId}&quot;\n    screen: &quot;ARPreview&quot;\n    platforms: [ios]  # ARKit only\n    params:\n      productId:\n        type: string\n        required: true\n    fallback:\n      screen: &quot;ProductDetail&quot;\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Code Generation<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Generate platform-specific configuration from the shared definition:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ generate-deep-link-configs.ts\nimport { parse } from &#39;yaml&#39;;\nimport { readFileSync, writeFileSync } from &#39;fs&#39;;\n\nconst config = parse(readFileSync(&#39;deep-links.yaml&#39;, &#39;utf8&#39;));\n\nfunction generateAASA(routes: Route[]) {\n  const iosRoutes = routes.filter(r =&gt; r.platforms.includes(&#39;ios&#39;));\n  return {\n    applinks: {\n      details: [{\n        appIDs: [process.env.APPLE_APP_ID],\n        components: iosRoutes.map(r =&gt; ({\n          &#39;\/&#39;: r.path.replace(\/\\{[^}]+\\}\/g, &#39;*&#39;)\n        }))\n      }]\n    }\n  };\n}\n\nfunction generateIntentFilters(routes: Route[]) {\n  const androidRoutes = routes.filter(r =&gt; r.platforms.includes(&#39;android&#39;));\n  const prefixes = new Set(\n    androidRoutes.map(r =&gt; &#39;\/&#39; + r.path.split(&#39;\/&#39;)[1])\n  );\n  return Array.from(prefixes);\n}\n\nfunction generateRouterTests(routes: Route[]) {\n  return routes.map(route =&gt; ({\n    testUrl: route.path.replace(\/\\{(\\w+)\\}\/g, &#39;test-$1&#39;),\n    expectedScreen: route.screen,\n    expectedParams: Object.keys(route.params).reduce((acc, key) =&gt; {\n      acc[key] = `test-${key}`;\n      return acc;\n    }, {} as Record&lt;string, string&gt;)\n  }));\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run this generator in CI to verify that platform configs match the source of truth.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Team Responsibilities<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Who Owns What<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table>\n<thead>\n<tr>\n<th>Component<\/th>\n<th>Owner<\/th>\n<th>Review Required From<\/th>\n<\/tr>\n<\/thead>\n<tbody><tr>\n<td><code>deep-links.yaml<\/code><\/td>\n<td>Product\/Engineering lead<\/td>\n<td>All platform teams<\/td>\n<\/tr>\n<tr>\n<td>AASA file<\/td>\n<td>Backend\/DevOps<\/td>\n<td>iOS team<\/td>\n<\/tr>\n<tr>\n<td>assetlinks.json<\/td>\n<td>Backend\/DevOps<\/td>\n<td>Android team<\/td>\n<\/tr>\n<tr>\n<td>iOS entitlements<\/td>\n<td>iOS team<\/td>\n<td>&#8211;<\/td>\n<\/tr>\n<tr>\n<td>AndroidManifest.xml<\/td>\n<td>Android team<\/td>\n<td>&#8211;<\/td>\n<\/tr>\n<tr>\n<td>Client-side routing<\/td>\n<td>Each platform team<\/td>\n<td>&#8211;<\/td>\n<\/tr>\n<tr>\n<td>Server-side fallback<\/td>\n<td>Backend team<\/td>\n<td>Product<\/td>\n<\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Change Process<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">When adding a new deep link route:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Proposal<\/strong>: Add the route to <code>deep-links.yaml<\/code> in a PR. Tag all platform teams for review.<\/li>\n<li><strong>Backend<\/strong>: Deploy AASA and assetlinks.json updates. These must go live before the app update.<\/li>\n<li><strong>iOS<\/strong>: Add the route to the iOS router. Update entitlements if the domain changed.<\/li>\n<li><strong>Android<\/strong>: Add the Intent filter. Update the Android router.<\/li>\n<li><strong>Web<\/strong>: Add the server-side fallback route.<\/li>\n<li><strong>QA<\/strong>: Test on all platforms before release.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The AASA and assetlinks.json updates must be deployed first because iOS and Android cache these files. If the app ships with a new route but the verification files have not been updated, the links will not work until the cache refreshes (up to 24 hours on iOS, variable on Android).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Shared Testing<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Contract Tests<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Write tests that verify each platform&#39;s router handles the same URLs consistently:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ shared-deep-link-tests.json\n{\n  &quot;tests&quot;: [\n    {\n      &quot;url&quot;: &quot;https:\/\/yourdomain.com\/products\/abc123&quot;,\n      &quot;expectedScreen&quot;: &quot;ProductDetail&quot;,\n      &quot;expectedParams&quot;: { &quot;productId&quot;: &quot;abc123&quot; }\n    },\n    {\n      &quot;url&quot;: &quot;https:\/\/yourdomain.com\/products\/abc123?ref=email&amp;campaign=summer&quot;,\n      &quot;expectedScreen&quot;: &quot;ProductDetail&quot;,\n      &quot;expectedParams&quot;: {\n        &quot;productId&quot;: &quot;abc123&quot;,\n        &quot;ref&quot;: &quot;email&quot;,\n        &quot;campaign&quot;: &quot;summer&quot;\n      }\n    },\n    {\n      &quot;url&quot;: &quot;https:\/\/yourdomain.com\/offers\/deal456&quot;,\n      &quot;expectedScreen&quot;: &quot;OfferDetail&quot;,\n      &quot;expectedParams&quot;: { &quot;offerId&quot;: &quot;deal456&quot; }\n    },\n    {\n      &quot;url&quot;: &quot;https:\/\/yourdomain.com\/nonexistent\/path&quot;,\n      &quot;expectedScreen&quot;: null,\n      &quot;expectedParams&quot;: {}\n    }\n  ]\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Each platform team writes a test runner that reads this file and verifies their router:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>iOS (XCTest)<\/strong>:<\/p>\n\n\n\n<pre><code class=\"language-swift\">func testSharedDeepLinkContracts() {\n    let tests = loadSharedTests()\n    for test in tests {\n        let result = DeepLinkRouter.match(url: test.url)\n        XCTAssertEqual(result?.screen, test.expectedScreen)\n        XCTAssertEqual(result?.params, test.expectedParams)\n    }\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Android (JUnit)<\/strong>:<\/p>\n\n\n\n<pre><code class=\"language-kotlin\">@Test\nfun `shared deep link contracts`() {\n    val tests = loadSharedTests()\n    tests.forEach { test -&gt;\n        val result = DeepLinkRouter.match(test.url)\n        assertEquals(test.expectedScreen, result?.screen)\n        assertEquals(test.expectedParams, result?.params)\n    }\n}\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">End-to-End Testing<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Run E2E tests on each platform in CI:<\/p>\n\n\n\n<pre><code class=\"language-bash\"># iOS (Xcode Cloud or Bitrise)\nxcrun simctl openurl booted &quot;https:\/\/yourdomain.com\/products\/abc123&quot;\n# Assert the ProductDetail screen is visible\n\n# Android (Firebase Test Lab)\nadb shell am start -a android.intent.action.VIEW \\\n  -d &quot;https:\/\/yourdomain.com\/products\/abc123&quot; \\\n  -c android.intent.category.BROWSABLE\n# Assert the ProductDetail screen is visible\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Versioning Deep Links<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When a deep link path changes, you need backward compatibility. Old links shared via email, social media, or QR codes may circulate indefinitely.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Server-Side Redirects<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Handle deprecated paths with server-side redirects:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ Redirect old paths to new ones\nconst redirects: Record&lt;string, string&gt; = {\n  &#39;\/item\/&#39;: &#39;\/products\/&#39;,     \/\/ Old path format\n  &#39;\/deal\/&#39;: &#39;\/offers\/&#39;,       \/\/ Renamed route\n  &#39;\/invite\/&#39;: &#39;\/referral\/&#39;    \/\/ Renamed route\n};\n\napp.use((req, res, next) =&gt; {\n  for (const [oldPrefix, newPrefix] of Object.entries(redirects)) {\n    if (req.path.startsWith(oldPrefix)) {\n      const newPath = req.path.replace(oldPrefix, newPrefix);\n      res.redirect(301, newPath + (req.originalUrl.includes(&#39;?&#39;)\n        ? &#39;?&#39; + req.originalUrl.split(&#39;?&#39;)[1]\n        : &#39;&#39;));\n      return;\n    }\n  }\n  next();\n});\n<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Client-Side Compatibility<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Keep old routes in the client router for at least two release cycles:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">function matchRoute(url: string): RouteMatch | null {\n  \/\/ Current routes\n  const currentMatch = matchCurrentRoutes(url);\n  if (currentMatch) return currentMatch;\n\n  \/\/ Legacy routes (remove after v3.0)\n  const legacyMatch = matchLegacyRoutes(url);\n  if (legacyMatch) {\n    analytics.track(&#39;legacy_deep_link&#39;, { url });\n    return legacyMatch;\n  }\n\n  return null;\n}\n<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">Monitoring<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Track deep link health across platforms:<\/p>\n\n\n\n<pre><code class=\"language-typescript\">\/\/ Log every deep link attempt\nfunction trackDeepLink(url: string, result: &#39;matched&#39; | &#39;unmatched&#39;, platform: string) {\n  analytics.track(&#39;deep_link_received&#39;, {\n    url,\n    result,\n    platform,\n    screen: result === &#39;matched&#39; ? matchedScreen : null,\n    timestamp: Date.now()\n  });\n}\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Set up alerts for:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Unmatched deep links<\/strong> spiking on one platform (missing route).<\/li>\n<li><strong>AASA validation failures<\/strong> (check with <code>curl -v https:\/\/yourdomain.com\/.well-known\/apple-app-site-association<\/code>).<\/li>\n<li><strong>assetlinks.json validation failures<\/strong> (check with <a href=\"https:\/\/developers.google.com\/digital-asset-links\/tools\/generator\" rel=\"nofollow noopener\" target=\"_blank\">Google&#39;s Statement List Generator<\/a>).<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Documentation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Maintain a living document that all teams reference:<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Deep Link Registry<\/h3>\n\n\n\n<figure class=\"wp-block-table\"><table>\n<thead>\n<tr>\n<th>Route<\/th>\n<th>Screen<\/th>\n<th>Platforms<\/th>\n<th>Added<\/th>\n<th>Owner<\/th>\n<\/tr>\n<\/thead>\n<tbody><tr>\n<td><code>\/products\/{productId}<\/code><\/td>\n<td>ProductDetail<\/td>\n<td>iOS, Android, Web<\/td>\n<td>v1.0<\/td>\n<td>Product<\/td>\n<\/tr>\n<tr>\n<td><code>\/offers\/{offerId}<\/code><\/td>\n<td>OfferDetail<\/td>\n<td>iOS, Android, Web<\/td>\n<td>v1.2<\/td>\n<td>Marketing<\/td>\n<\/tr>\n<tr>\n<td><code>\/referral\/{referrerId}<\/code><\/td>\n<td>Referral<\/td>\n<td>iOS, Android, Web<\/td>\n<td>v1.5<\/td>\n<td>Growth<\/td>\n<\/tr>\n<tr>\n<td><code>\/ar-preview\/{productId}<\/code><\/td>\n<td>ARPreview<\/td>\n<td>iOS<\/td>\n<td>v2.0<\/td>\n<td>Product<\/td>\n<\/tr>\n<\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Include:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>What query parameters are supported.<\/li>\n<li>What happens when the app is not installed.<\/li>\n<li>What the expected back navigation behavior is.<\/li>\n<li>Who to contact if the link breaks.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Tolinku for Multi-Platform Teams<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/tolinku.com\/features\/deep-linking\">Tolinku<\/a> centralizes deep link configuration. <a href=\"https:\/\/tolinku.com\/docs\/user-guide\/routes\/\">Routes<\/a> are defined in the Tolinku dashboard, and the platform automatically generates and hosts AASA and assetlinks.json files. This eliminates the need for manual file management and reduces the chance of platform-specific files getting out of sync. <a href=\"https:\/\/tolinku.com\/features\/analytics\">Analytics<\/a> provide visibility into which links are working across platforms.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For testing deep links, see <a href=\"https:\/\/tolinku.com\/blog\/cross-platform-testing-deep-links\/\">testing deep links across platforms and devices<\/a>. For the cross-platform overview, 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>Coordinate deep linking across iOS, Android, and web teams. Establish shared conventions, testing processes, and deployment strategies.<\/p>\n","protected":false},"author":2,"featured_media":1899,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"Deep Linking for Multi-Platform Engineering Teams","rank_math_description":"Coordinate deep linking across iOS, Android, and web teams. Establish shared conventions, testing processes, and deployment strategies.","rank_math_focus_keyword":"deep linking multi-platform team","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-linking-multi-platform-teams.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-linking-multi-platform-teams.png","footnotes":""},"categories":[15],"tags":[25,23,305,156,20,576,24,69,80,22],"class_list":["post-1900","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-engineering-teams","tag-ios","tag-mobile-development","tag-testing","tag-universal-links"],"_links":{"self":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1900","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=1900"}],"version-history":[{"count":1,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1900\/revisions"}],"predecessor-version":[{"id":1901,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/posts\/1900\/revisions\/1901"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media\/1899"}],"wp:attachment":[{"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/media?parent=1900"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/categories?post=1900"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tolinku.com\/blog\/wp-json\/wp\/v2\/tags?post=1900"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}