Skip to content
Tolinku
Tolinku
Sign In Start Free
Engineering · · 6 min read

Deep Linking for Multi-Platform Engineering Teams

By Tolinku Staff
|
Tolinku cross platform dashboard screenshot for engineering blog posts

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.

For shared routing patterns, see cross-platform link handling patterns. For the cross-platform overview, see cross-platform deep linking guide for 2026.

The Coordination Problem

A single deep link route (e.g., /products/:productId) requires changes in up to six places:

  1. AASA file (hosted on your domain, for iOS)
  2. assetlinks.json (hosted on your domain, for Android)
  3. iOS entitlements (Associated Domains in Xcode)
  4. AndroidManifest.xml (Intent filter with path prefix)
  5. Client-side router (iOS, Android, and/or cross-platform framework)
  6. Server-side fallback (for users without the app)

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.

Single Source of Truth

Define all deep link routes in one place. Every team reads from this source.

Route Configuration File

# deep-links.yaml
routes:
  - path: "/products/{productId}"
    screen: "ProductDetail"
    platforms: [ios, android, web]
    params:
      productId:
        type: string
        required: true
    queryParams:
      ref:
        type: string
        required: false
      campaign:
        type: string
        required: false

  - path: "/offers/{offerId}"
    screen: "OfferDetail"
    platforms: [ios, android, web]
    params:
      offerId:
        type: string
        required: true

  - path: "/referral/{referrerId}"
    screen: "Referral"
    platforms: [ios, android, web]
    params:
      referrerId:
        type: string
        required: true

  - path: "/ar-preview/{productId}"
    screen: "ARPreview"
    platforms: [ios]  # ARKit only
    params:
      productId:
        type: string
        required: true
    fallback:
      screen: "ProductDetail"

Code Generation

Generate platform-specific configuration from the shared definition:

// generate-deep-link-configs.ts
import { parse } from 'yaml';
import { readFileSync, writeFileSync } from 'fs';

const config = parse(readFileSync('deep-links.yaml', 'utf8'));

function generateAASA(routes: Route[]) {
  const iosRoutes = routes.filter(r => r.platforms.includes('ios'));
  return {
    applinks: {
      details: [{
        appIDs: [process.env.APPLE_APP_ID],
        components: iosRoutes.map(r => ({
          '/': r.path.replace(/\{[^}]+\}/g, '*')
        }))
      }]
    }
  };
}

function generateIntentFilters(routes: Route[]) {
  const androidRoutes = routes.filter(r => r.platforms.includes('android'));
  const prefixes = new Set(
    androidRoutes.map(r => '/' + r.path.split('/')[1])
  );
  return Array.from(prefixes);
}

function generateRouterTests(routes: Route[]) {
  return routes.map(route => ({
    testUrl: route.path.replace(/\{(\w+)\}/g, 'test-$1'),
    expectedScreen: route.screen,
    expectedParams: Object.keys(route.params).reduce((acc, key) => {
      acc[key] = `test-${key}`;
      return acc;
    }, {} as Record<string, string>)
  }));
}

Run this generator in CI to verify that platform configs match the source of truth.

Team Responsibilities

Who Owns What

Component Owner Review Required From
deep-links.yaml Product/Engineering lead All platform teams
AASA file Backend/DevOps iOS team
assetlinks.json Backend/DevOps Android team
iOS entitlements iOS team
AndroidManifest.xml Android team
Client-side routing Each platform team
Server-side fallback Backend team Product

Change Process

When adding a new deep link route:

  1. Proposal: Add the route to deep-links.yaml in a PR. Tag all platform teams for review.
  2. Backend: Deploy AASA and assetlinks.json updates. These must go live before the app update.
  3. iOS: Add the route to the iOS router. Update entitlements if the domain changed.
  4. Android: Add the Intent filter. Update the Android router.
  5. Web: Add the server-side fallback route.
  6. QA: Test on all platforms before release.

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).

Shared Testing

Contract Tests

Write tests that verify each platform's router handles the same URLs consistently:

// shared-deep-link-tests.json
{
  "tests": [
    {
      "url": "https://yourdomain.com/products/abc123",
      "expectedScreen": "ProductDetail",
      "expectedParams": { "productId": "abc123" }
    },
    {
      "url": "https://yourdomain.com/products/abc123?ref=email&campaign=summer",
      "expectedScreen": "ProductDetail",
      "expectedParams": {
        "productId": "abc123",
        "ref": "email",
        "campaign": "summer"
      }
    },
    {
      "url": "https://yourdomain.com/offers/deal456",
      "expectedScreen": "OfferDetail",
      "expectedParams": { "offerId": "deal456" }
    },
    {
      "url": "https://yourdomain.com/nonexistent/path",
      "expectedScreen": null,
      "expectedParams": {}
    }
  ]
}

Each platform team writes a test runner that reads this file and verifies their router:

iOS (XCTest):

func testSharedDeepLinkContracts() {
    let tests = loadSharedTests()
    for test in tests {
        let result = DeepLinkRouter.match(url: test.url)
        XCTAssertEqual(result?.screen, test.expectedScreen)
        XCTAssertEqual(result?.params, test.expectedParams)
    }
}

Android (JUnit):

@Test
fun `shared deep link contracts`() {
    val tests = loadSharedTests()
    tests.forEach { test ->
        val result = DeepLinkRouter.match(test.url)
        assertEquals(test.expectedScreen, result?.screen)
        assertEquals(test.expectedParams, result?.params)
    }
}

End-to-End Testing

Run E2E tests on each platform in CI:

# iOS (Xcode Cloud or Bitrise)
xcrun simctl openurl booted "https://yourdomain.com/products/abc123"
# Assert the ProductDetail screen is visible

# Android (Firebase Test Lab)
adb shell am start -a android.intent.action.VIEW \
  -d "https://yourdomain.com/products/abc123" \
  -c android.intent.category.BROWSABLE
# Assert the ProductDetail screen is visible

When a deep link path changes, you need backward compatibility. Old links shared via email, social media, or QR codes may circulate indefinitely.

Server-Side Redirects

Handle deprecated paths with server-side redirects:

// Redirect old paths to new ones
const redirects: Record<string, string> = {
  '/item/': '/products/',     // Old path format
  '/deal/': '/offers/',       // Renamed route
  '/invite/': '/referral/'    // Renamed route
};

app.use((req, res, next) => {
  for (const [oldPrefix, newPrefix] of Object.entries(redirects)) {
    if (req.path.startsWith(oldPrefix)) {
      const newPath = req.path.replace(oldPrefix, newPrefix);
      res.redirect(301, newPath + (req.originalUrl.includes('?')
        ? '?' + req.originalUrl.split('?')[1]
        : ''));
      return;
    }
  }
  next();
});

Client-Side Compatibility

Keep old routes in the client router for at least two release cycles:

function matchRoute(url: string): RouteMatch | null {
  // Current routes
  const currentMatch = matchCurrentRoutes(url);
  if (currentMatch) return currentMatch;

  // Legacy routes (remove after v3.0)
  const legacyMatch = matchLegacyRoutes(url);
  if (legacyMatch) {
    analytics.track('legacy_deep_link', { url });
    return legacyMatch;
  }

  return null;
}

Monitoring

Track deep link health across platforms:

// Log every deep link attempt
function trackDeepLink(url: string, result: 'matched' | 'unmatched', platform: string) {
  analytics.track('deep_link_received', {
    url,
    result,
    platform,
    screen: result === 'matched' ? matchedScreen : null,
    timestamp: Date.now()
  });
}

Set up alerts for:

  • Unmatched deep links spiking on one platform (missing route).
  • AASA validation failures (check with curl -v https://yourdomain.com/.well-known/apple-app-site-association).
  • assetlinks.json validation failures (check with Google's Statement List Generator).

Documentation

Maintain a living document that all teams reference:

Route Screen Platforms Added Owner
/products/{productId} ProductDetail iOS, Android, Web v1.0 Product
/offers/{offerId} OfferDetail iOS, Android, Web v1.2 Marketing
/referral/{referrerId} Referral iOS, Android, Web v1.5 Growth
/ar-preview/{productId} ARPreview iOS v2.0 Product

Include:

  • What query parameters are supported.
  • What happens when the app is not installed.
  • What the expected back navigation behavior is.
  • Who to contact if the link breaks.

Tolinku for Multi-Platform Teams

Tolinku centralizes deep link configuration. Routes 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. Analytics provide visibility into which links are working across platforms.

For testing deep links, see testing deep links across platforms and devices. For the cross-platform overview, see cross-platform deep linking guide for 2026.

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.