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

Platform-Specific Link Routing in Cross-Platform Apps

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

Most deep link routes work identically on iOS and Android: the same URL maps to the same screen. But some routes need platform-specific behavior. iOS may support features Android does not (Handoff, Spotlight indexing). Android may use Intent-based navigation that has no iOS equivalent. This article covers patterns for handling these differences while keeping your codebase maintainable.

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

Where Platform Differences Appear

Deep link routing has platform-specific behavior at three levels:

1. URL Reception

iOS and Android receive deep links through different APIs:

Aspect iOS Android
Mechanism Universal Links (AASA) App Links (assetlinks.json)
Entry point application(_:continue:restorationHandler:) onCreate() / onNewIntent() with Intent
URL format Same HTTPS URL Same HTTPS URL
Verification AASA file assetlinks.json

2. Navigation

Each platform's navigation system works differently:

  • iOS uses UINavigationController push/pop, UITabBarController, or SwiftUI NavigationStack.
  • Android uses Activities, Fragments, and the Navigation component.
  • Cross-platform frameworks (React Native, Flutter, Capacitor) abstract these but sometimes expose differences.

3. Feature Availability

Some features only exist on one platform:

  • iOS only: Handoff, Spotlight indexing, Universal Clipboard
  • Android only: Instant Apps, Custom Tabs fallback, multi-window deep links
  • Behavioral differences: Back button handling, task stack management, split-screen behavior

Pattern: Platform-Aware Route Configuration

Define routes with optional platform-specific overrides:

interface Route {
  path: string;
  screen: string;
  platforms: ('ios' | 'android' | 'web')[];
  iosConfig?: {
    handoffActivity?: string;
    spotlightTitle?: string;
  };
  androidConfig?: {
    taskAffinity?: string;
    launchMode?: 'singleTop' | 'singleTask';
  };
}

const routes: Route[] = [
  {
    path: '/products/:productId',
    screen: 'ProductDetail',
    platforms: ['ios', 'android', 'web']
  },
  {
    path: '/ar-preview/:productId',
    screen: 'ARPreview',
    platforms: ['ios'], // ARKit only
    iosConfig: {
      spotlightTitle: 'AR Preview'
    }
  },
  {
    path: '/instant/:productId',
    screen: 'InstantPreview',
    platforms: ['android'], // Android Instant Apps
    androidConfig: {
      launchMode: 'singleTask'
    }
  },
  {
    path: '/share/:shareId',
    screen: 'SharedContent',
    platforms: ['ios', 'android', 'web'],
    iosConfig: {
      handoffActivity: 'com.yourapp.viewing'
    }
  }
];

Implementing Platform-Specific Routing

Shared Router with Platform Filter

function matchRoute(
  url: string,
  platform: 'ios' | 'android' | 'web'
): RouteMatch | null {
  const parsed = new URL(url);
  const path = parsed.pathname;

  for (const route of routes) {
    // Skip routes not available on this platform
    if (!route.platforms.includes(platform)) continue;

    const match = matchPath(path, route.path);
    if (match) {
      return {
        screen: route.screen,
        params: {
          ...match.params,
          ...Object.fromEntries(parsed.searchParams)
        },
        platformConfig: platform === 'ios'
          ? route.iosConfig
          : route.androidConfig
      };
    }
  }

  return null;
}

function matchPath(
  actual: string,
  pattern: string
): { params: Record<string, string> } | null {
  const paramNames: string[] = [];
  const regex = new RegExp(
    '^' + pattern.replace(/:([^/]+)/g, (_, name) => {
      paramNames.push(name);
      return '([^/]+)';
    }) + '$'
  );

  const match = actual.match(regex);
  if (!match) return null;

  const params: Record<string, string> = {};
  paramNames.forEach((name, i) => {
    params[name] = match[i + 1];
  });
  return { params };
}

iOS Implementation

func handleDeepLink(_ url: URL) {
    guard let route = DeepLinkRouter.match(
        url: url.absoluteString,
        platform: "ios"
    ) else {
        navigateToHome()
        return
    }

    // Handle iOS-specific config
    if let handoffActivity = route.platformConfig?.handoffActivity {
        setupHandoff(activityType: handoffActivity, url: url)
    }

    navigateTo(screen: route.screen, params: route.params)
}

private func setupHandoff(activityType: String, url: URL) {
    let activity = NSUserActivity(activityType: activityType)
    activity.webpageURL = url
    activity.isEligibleForHandoff = true
    UIApplication.shared.userActivity = activity
}

Android Implementation

fun handleDeepLink(intent: Intent) {
    val uri = intent.data ?: return
    val route = DeepLinkRouter.match(
        url = uri.toString(),
        platform = "android"
    ) ?: run {
        navigateToHome()
        return
    }

    // Handle Android-specific config
    route.platformConfig?.launchMode?.let { mode ->
        when (mode) {
            "singleTask" -> {
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or
                    Intent.FLAG_ACTIVITY_CLEAR_TOP)
            }
            "singleTop" -> {
                intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
            }
        }
    }

    navigateTo(screen = route.screen, params = route.params)
}

Handling Fallbacks Between Platforms

When a deep link targets a platform-specific feature, define fallback behavior for the other platform:

interface Route {
  path: string;
  screen: string;
  platforms: ('ios' | 'android' | 'web')[];
  fallback?: {
    screen: string;
    message?: string;
  };
}

const routes: Route[] = [
  {
    path: '/ar-preview/:productId',
    screen: 'ARPreview',
    platforms: ['ios'],
    fallback: {
      screen: 'ProductDetail',
      message: 'AR preview is only available on iOS'
    }
  }
];

function resolveRoute(url: string, platform: string): RouteMatch {
  const route = findMatchingRoute(url);

  if (!route) {
    return { screen: 'Home', params: {} };
  }

  if (!route.platforms.includes(platform)) {
    // Platform not supported, use fallback
    if (route.fallback) {
      return {
        screen: route.fallback.screen,
        params: extractParams(url, route.path),
        message: route.fallback.message
      };
    }
    return { screen: 'Home', params: {} };
  }

  return {
    screen: route.screen,
    params: extractParams(url, route.path)
  };
}

Platform-specific routes affect your verification files. You only need to list paths in the verification file for the platform that supports them.

AASA (iOS)

Include only iOS and shared routes:

{
  "applinks": {
    "details": [{
      "appIDs": ["TEAMID.com.yourcompany.yourapp"],
      "components": [
        { "/": "/products/*" },
        { "/": "/offers/*" },
        { "/": "/ar-preview/*" },
        { "/": "/share/*" }
      ]
    }]
  }
}

AndroidManifest.xml

Include only Android and shared routes:

<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https"
          android:host="yourdomain.com"
          android:pathPrefix="/products" />
    <data android:scheme="https"
          android:host="yourdomain.com"
          android:pathPrefix="/offers" />
    <data android:scheme="https"
          android:host="yourdomain.com"
          android:pathPrefix="/instant" />
    <data android:scheme="https"
          android:host="yourdomain.com"
          android:pathPrefix="/share" />
</intent-filter>

Back Navigation Differences

After a deep link opens, back button behavior differs significantly:

iOS

  • The system provides a "Back to [source app]" link in the status bar.
  • Your app's navigation stack starts fresh (unless the app was already running).
  • Users expect swiping back to go to the parent screen, not the source app.
func navigateFromDeepLink(to screen: String, params: [String: String]) {
    // Build a navigation stack so the user can navigate back
    let homeVC = HomeViewController()
    let detailVC = makeViewController(screen: screen, params: params)

    navigationController?.setViewControllers([homeVC, detailVC], animated: false)
}

Android

  • The back button should follow the app's task stack.
  • Use TaskStackBuilder to create a proper back stack:
fun navigateFromDeepLink(screen: String, params: Map<String, String>) {
    val detailIntent = makeIntent(screen, params)

    TaskStackBuilder.create(this)
        .addNextIntentWithParentStack(detailIntent)
        .startActivities()
}

Cross-Platform Framework Considerations

React Native

React Native's Linking API abstracts the platform entry point, but you may still need platform checks for specific behaviors:

import { Platform, Linking } from 'react-native';

function handleDeepLink(url: string) {
  const route = matchRoute(url, Platform.OS);

  if (!route) {
    navigation.navigate('Home');
    return;
  }

  // Platform-specific handling
  if (Platform.OS === 'ios' && route.iosConfig?.spotlightTitle) {
    // Index in Spotlight
    indexInSpotlight(route);
  }

  navigation.navigate(route.screen, route.params);
}

Flutter

Flutter's route handling is platform-agnostic, but platform channels can be used for platform-specific features:

void handleDeepLink(Uri uri) {
  final route = matchRoute(uri.toString());

  if (route == null) {
    Navigator.of(context).pushReplacementNamed('/');
    return;
  }

  // Platform-specific features via method channel
  if (Platform.isIOS && route.handoffActivity != null) {
    _methodChannel.invokeMethod('setupHandoff', {
      'activityType': route.handoffActivity,
      'url': uri.toString(),
    });
  }

  Navigator.of(context).pushNamed(
    route.screen,
    arguments: route.params,
  );
}

Testing Platform-Specific Routes

Test each platform independently:

# iOS: test an iOS-only route
xcrun simctl openurl booted "https://yourdomain.com/ar-preview/product123"

# Android: test an Android-only route
adb shell am start -a android.intent.action.VIEW \
  -d "https://yourdomain.com/instant/product123" \
  -c android.intent.category.BROWSABLE

# Test shared route on both
xcrun simctl openurl booted "https://yourdomain.com/products/abc123"
adb shell am start -a android.intent.action.VIEW \
  -d "https://yourdomain.com/products/abc123" \
  -c android.intent.category.BROWSABLE

Test fallback behavior by sending platform-specific URLs to the wrong platform:

# Send iOS-only route to Android (should fallback)
adb shell am start -a android.intent.action.VIEW \
  -d "https://yourdomain.com/ar-preview/product123" \
  -c android.intent.category.BROWSABLE

Tolinku for Multi-Platform Routing

Tolinku hosts both AASA and assetlinks.json verification files. You configure your iOS and Android app details in the Appspace settings, and Tolinku generates the correct verification files for each platform. Routes can include dynamic parameters that work consistently across platforms.

For routing fundamentals, see deep link routing: how to route users to the right screen. For shared routing logic, see cross-platform link handling patterns.

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.