Most mobile teams build with cross-platform frameworks. React Native and Flutter dominate, but Kotlin Multiplatform, .NET MAUI, and Capacitor are all growing. These frameworks let you write shared UI and business logic once, then ship to iOS and Android from a single codebase. Deep linking, however, is where the "write once" promise breaks down.
Deep links require platform-specific configuration on both iOS and Android. You need Apple's apple-app-site-association file, Google's assetlinks.json, entitlements in Xcode, intent filters in the Android manifest, and framework-specific bridge code to receive those links in your Dart or JavaScript layer. Every cross-platform framework handles this differently, and none of them abstract it away completely.
This guide covers cross-platform deep linking implementation across React Native, Flutter, Kotlin Multiplatform, .NET MAUI, and Capacitor. You'll find working code, native configuration requirements, navigation patterns, and testing strategies for each.
The Cross-Platform Deep Linking Challenge
When a user taps a link on their phone, the operating system decides what happens. If the link matches a registered Universal Link (iOS) or App Link (Android), the OS opens the app directly. This decision happens at the OS level, before your framework code runs.
That creates a layered problem:
- Native configuration: Each platform needs its own manifest entries, entitlements, and server-hosted verification files.
- Framework bridging: The native deep link event must cross the bridge into your JavaScript, Dart, or Kotlin code.
- Navigation routing: Once the link arrives in your framework layer, you need to parse it and navigate to the correct screen.
- Deferred deep links: If the app wasn't installed when the user tapped the link, you need a way to recover that context after installation.
No cross-platform framework handles all four layers for you. Frameworks typically address layer 2 (bridging) and layer 3 (navigation), but layers 1 and 4 remain your responsibility.
Deep Linking in React Native
For a dedicated deep dive into React Native specifically, see our React Native deep linking setup guide. React Native provides the Linking API for receiving deep links. Combined with React Navigation's deep linking support, you can wire up URL-to-screen mapping with minimal native code.
Setting Up the Linking API
React Navigation accepts a linking configuration that maps URL paths to screens:
// App.tsx
import { NavigationContainer } from '@react-navigation/native';
const linking = {
prefixes: ['https://myapp.com', 'myapp://'],
config: {
screens: {
Home: '',
Product: 'product/:id',
Profile: 'user/:username',
Settings: {
path: 'settings',
screens: {
Notifications: 'notifications',
Privacy: 'privacy',
},
},
},
},
};
export default function App() {
return (
<NavigationContainer linking={linking} fallback={<LoadingScreen />}>
<RootNavigator />
</NavigationContainer>
);
}
When the app opens via https://myapp.com/product/42, React Navigation parses the URL, matches it to the Product screen, and passes { id: '42' } as route params.
Handling Deep Links Manually
For cases where you need more control (authentication checks, analytics, redirects), you can intercept links before navigation:
import { Linking } from 'react-native';
import { useEffect } from 'react';
function useDeepLinkHandler() {
useEffect(() => {
// Handle link that opened the app (cold start)
Linking.getInitialURL().then((url) => {
if (url) handleDeepLink(url);
});
// Handle link while app is in foreground (warm start)
const subscription = Linking.addEventListener('url', ({ url }) => {
handleDeepLink(url);
});
return () => subscription.remove();
}, []);
}
function handleDeepLink(url: string) {
const parsed = new URL(url);
const path = parsed.pathname;
// Check auth, log analytics, then navigate
if (path.startsWith('/product/')) {
const productId = path.split('/')[2];
navigate('Product', { id: productId });
}
}
Native Configuration for React Native
Even though your routing logic lives in JavaScript, you still need native setup.
iOS (Xcode):
- Enable the "Associated Domains" capability and add
applinks:myapp.com. - In
AppDelegate.mm, ensure theapplication:continueUserActivity:restorationHandler:method passes Universal Links to React Native. Most React Native templates include this by default.
Android (AndroidManifest.xml):
<activity
android:name=".MainActivity"
android:launchMode="singleTask">
<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="myapp.com"
android:pathPrefix="/product" />
</intent-filter>
</activity>
The android:autoVerify="true" attribute tells Android to check your assetlinks.json file at https://myapp.com/.well-known/assetlinks.json to verify ownership.
Deep Linking in Flutter
Flutter's deep linking story has matured significantly. For a complete Flutter-specific walkthrough, see our Flutter deep linking setup guide. The recommended approach uses the go_router package, which integrates directly with Flutter's Router API.
Configuring go_router
// router.dart
import 'package:go_router/go_router.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/product/:id',
builder: (context, state) {
final productId = state.pathParameters['id']!;
return ProductScreen(id: productId);
},
),
GoRoute(
path: '/user/:username',
builder: (context, state) {
final username = state.pathParameters['username']!;
return ProfileScreen(username: username);
},
),
],
errorBuilder: (context, state) => const NotFoundScreen(),
);
// main.dart
import 'package:flutter/material.dart';
void main() {
runApp(
MaterialApp.router(
routerConfig: router,
),
);
}
When a deep link opens the app, Flutter's engine passes the URL to go_router, which matches the path and renders the correct screen. No manual MethodChannel code is needed for basic deep linking.
Handling Deep Links with Guards
For authenticated routes or deferred link processing, use go_router's redirect feature:
final router = GoRouter(
redirect: (context, state) {
final isLoggedIn = authService.isLoggedIn;
final isGoingToLogin = state.matchedLocation == '/login';
if (!isLoggedIn && !isGoingToLogin) {
// Store the deep link destination for after login
authService.pendingDeepLink = state.uri.toString();
return '/login';
}
if (isLoggedIn && isGoingToLogin) {
return authService.pendingDeepLink ?? '/';
}
return null; // no redirect
},
routes: [ /* ... */ ],
);
Native Configuration for Flutter
iOS: Add FlutterDeepLinkingEnabled as a boolean YES in your Info.plist, and configure Associated Domains in Runner.entitlements:
<!-- ios/Runner/Info.plist -->
<key>FlutterDeepLinkingEnabled</key>
<true/>
Android: Add intent filters in AndroidManifest.xml, just like with React Native. Flutter reads the incoming intent automatically when FlutterDeepLinkingEnabled is configured (or when using go_router, which handles it internally).
Flutter's documentation on deep linking setup walks through each step in detail.
Deep Linking in Other Frameworks
Kotlin Multiplatform (KMP)
Kotlin Multiplatform shares business logic across iOS and Android while keeping native UIs. Deep link handling typically stays in the platform-specific layer, but you can share the URL parsing logic:
// shared/src/commonMain/kotlin/DeepLinkParser.kt
data class DeepLinkRoute(
val screen: String,
val params: Map<String, String>
)
fun parseDeepLink(url: String): DeepLinkRoute? {
val path = url.substringAfter("myapp.com")
.substringBefore("?")
.trim('/')
val segments = path.split("/")
return when {
segments.size == 2 && segments[0] == "product" ->
DeepLinkRoute("product", mapOf("id" to segments[1]))
segments.size == 2 && segments[0] == "user" ->
DeepLinkRoute("profile", mapOf("username" to segments[1]))
else -> null
}
}
Each platform calls parseDeepLink() from its native deep link handler and routes accordingly. On Android, you call it from onCreate() or onNewIntent(). On iOS, you call it from application(_:continue:restorationHandler:).
.NET MAUI
.NET MAUI uses the AppAction API and custom URI schemes. For App Links and Universal Links, you configure platform-specific handlers in the Platforms/ directories and route through the shared App.xaml.cs.
Capacitor (Ionic)
Capacitor wraps a web app in a native shell. The @capacitor/app plugin provides appUrlOpen events:
import { App } from '@capacitor/app';
App.addListener('appUrlOpen', (event) => {
const url = new URL(event.url);
const path = url.pathname;
if (path.startsWith('/product/')) {
router.push(path);
}
});
Capacitor still requires the same native configuration (Associated Domains on iOS, intent filters on Android). The web layer just receives the URL after the native shell intercepts it.
Native Configuration You Cannot Skip
Regardless of your framework, two server-side files are mandatory for verified deep links.
Apple App Site Association (AASA)
Hosted at https://yourdomain.com/.well-known/apple-app-site-association:
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.yourcompany.yourapp"],
"components": [
{ "/": "/product/*", "comment": "Product pages" },
{ "/": "/user/*", "comment": "User profiles" }
]
}
]
}
}
Apple's CDN fetches this file when your app is installed (and periodically after). If it's misconfigured, Universal Links won't work at all. The file must be served with Content-Type: application/json and must not require authentication or redirect. See Apple's Universal Links documentation for the full specification.
Android Asset Links
Hosted at https://yourdomain.com/.well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app_namespace",
"package_name": "com.yourcompany.yourapp",
"sha256_cert_fingerprints": [
"AA:BB:CC:..."
]
}
}]
Android verifies this file during app installation. The SHA-256 fingerprint must match your app's signing certificate. Debug builds use a different certificate than release builds, so you'll need both fingerprints during development. Google's App Links documentation covers the verification process.
These files live on your web server, not in your app. Cross-platform frameworks don't generate or manage them. This is one reason teams use a deep linking service: it handles the verification files, fallback logic, and routing configuration in one place, so your server setup stays minimal.
Navigation Architecture for Deep Links
The biggest mistake in cross-platform deep linking is scattering link-handling logic across multiple screens or components. Instead, centralize your routing.
The Centralized Router Pattern
Define a single function or module that maps URLs to navigation actions:
// deepLinkRouter.ts (React Native)
type RouteHandler = (params: Record<string, string>) => void;
const routes: Array<{ pattern: RegExp; handler: RouteHandler }> = [
{
pattern: /^\/product\/([a-zA-Z0-9]+)$/,
handler: (params) => navigate('Product', { id: params.id }),
},
{
pattern: /^\/user\/([a-zA-Z0-9_]+)$/,
handler: (params) => navigate('Profile', { username: params.username }),
},
{
pattern: /^\/invite\/([a-zA-Z0-9]+)$/,
handler: (params) => navigate('AcceptInvite', { code: params.code }),
},
];
export function routeDeepLink(url: string): boolean {
const path = new URL(url).pathname;
for (const route of routes) {
const match = path.match(route.pattern);
if (match) {
const params = extractNamedParams(match);
route.handler(params);
return true;
}
}
return false; // no match
}
This pattern works identically in Dart, Kotlin, or TypeScript. The router is the single source of truth for URL-to-screen mapping. When you add a new deep link, you add one entry here instead of touching multiple files.
Typed Route Parameters
Type safety prevents an entire class of deep link bugs. In TypeScript projects, define your route params as types:
type DeepLinkRoutes = {
Product: { id: string };
Profile: { username: string };
Settings: undefined;
Order: { orderId: string; tab?: 'details' | 'tracking' };
};
In Dart, use classes or records:
sealed class DeepLinkRoute {
const DeepLinkRoute();
}
class ProductRoute extends DeepLinkRoute {
final String id;
const ProductRoute(this.id);
}
class ProfileRoute extends DeepLinkRoute {
final String username;
const ProfileRoute(this.username);
}
Typed routes make it impossible to navigate to a product screen without an ID, catching mistakes at compile time rather than in production.
Handling Deferred Deep Links in Cross-Platform Apps
Deferred deep linking is the hardest part of cross-platform deep linking. When a user taps a link but doesn't have your app installed, the link context needs to survive the entire install flow: redirect to the app store, download, install, and first launch.
Cross-platform frameworks provide no built-in support for deferred deep links. The app store install process erases URL context entirely. Recovering that context requires a server-side matching service that correlates the pre-install click with the post-install app open using device characteristics (IP address, user agent, device model, timestamp).
Implementing deferred deep linking from scratch involves:
- Recording click metadata on your server when the user taps the link.
- Redirecting to the appropriate app store.
- On first app launch, sending device metadata to your server.
- Matching the first-launch request against recent clicks.
- Returning the original deep link destination to the app.
The matching logic needs to handle edge cases: shared Wi-Fi networks (same IP, different devices), VPN users, and time delays between click and install. Probabilistic matching using multiple signals works well in practice, but it requires ongoing maintenance.
This is where a dedicated deep linking service saves real engineering time. Tolinku's deep linking features handle deferred deep links out of the box, including the server-side matching, fallback pages, and SDK integration for retrieving the deferred link on first launch.
Testing Cross-Platform Deep Links
Testing deep links is notoriously tedious because you can't test them in a simulator's browser the same way they work on a real device. Here's a framework-by-framework testing strategy.
React Native
Use npx uri-scheme to trigger deep links from the command line:
# iOS Simulator
xcrun simctl openurl booted "https://myapp.com/product/42"
# Android Emulator
adb shell am start -a android.intent.action.VIEW \
-d "https://myapp.com/product/42" \
com.yourcompany.yourapp
React Navigation also provides a LinkingContext for unit testing link resolution without needing a device.
Flutter
Flutter provides the --route flag for testing initial routes:
flutter run --route "/product/42"
For integration tests, use WidgetTester to verify navigation:
testWidgets('deep link navigates to product', (tester) async {
await tester.pumpWidget(
MaterialApp.router(routerConfig: router),
);
router.go('/product/42');
await tester.pumpAndSettle();
expect(find.byType(ProductScreen), findsOneWidget);
});
CI/CD Integration
Automate deep link testing in your CI pipeline:
- Run Android emulator tests with
adb shell am startcommands. - Run iOS simulator tests with
xcrun simctl openurl. - Validate your AASA file with Apple's search validation tool.
- Validate
assetlinks.jsonwith Google's Statement List Tester. - Include URL parsing unit tests for every supported deep link pattern.
Using Tolinku SDKs for Cross-Platform Deep Linking
If you'd rather not maintain verification files, fallback logic, deferred deep linking infrastructure, and per-platform configuration yourself, Tolinku's SDKs handle the heavy lifting.
React Native SDK
The Tolinku React Native SDK wraps the native iOS and Android SDKs and exposes a unified JavaScript API:
import { Tolinku } from '@tolinku/react-native-sdk';
// Initialize once at app start
Tolinku.init({ apiKey: 'tolk_pub_your_key' });
// Handle deferred deep links on first launch
Tolinku.getInitialLink().then((link) => {
if (link) {
routeDeepLink(link.url);
}
});
// Listen for deep links while app is running
Tolinku.onLink((link) => {
routeDeepLink(link.url);
});
The SDK handles the native Universal Links and App Links integration, deferred deep link resolution, and link metadata retrieval. Your routing logic stays in JavaScript.
Flutter SDK
The Tolinku Flutter SDK follows similar patterns using Dart streams:
import 'package:tolinku/tolinku.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Tolinku.init(apiKey: 'tolk_pub_your_key');
// Check for deferred deep link
final initialLink = await Tolinku.getInitialLink();
if (initialLink != null) {
// Route to the intended screen
}
runApp(const MyApp());
}
Both SDKs also work alongside the native iOS SDK and Android SDK, so if you ever move to fully native development, the same link infrastructure carries over. The Web SDK covers the web side, handling smart banners and link routing for your mobile web traffic.
Best Practices for Cross-Platform Deep Links
1. Keep URL patterns consistent across platforms. If /product/:id works on iOS, it should work on Android, and it should work on the web too. Use the same path structure everywhere.
2. Handle missing or invalid parameters gracefully. Deep links come from external sources: emails, push notifications, SMS, social media, QR codes. Users (and bots) will send malformed URLs. Validate parameters and show a helpful fallback screen instead of crashing.
function ProductScreen({ route }) {
const { id } = route.params;
if (!id || !isValidProductId(id)) {
return <NotFoundScreen message="Product not found" />;
}
return <ProductDetail id={id} />;
}
3. Test on real devices, not just simulators. Universal Links behave differently on physical devices than in the iOS Simulator. The Simulator doesn't fully replicate the AASA verification flow. Budget time for real-device testing before release.
4. Version your deep link routes. When you rename a screen or change URL structure, keep the old paths working. Broken deep links in emails or saved bookmarks create a terrible user experience. Map old paths to new destinations rather than removing them.
5. Log deep link events. Track which deep links users tap, whether they resolve successfully, and where failures happen. This data is essential for debugging and for understanding which entry points drive the most engagement.
6. Don't rely on custom URL schemes for primary deep linking. Custom schemes (myapp://) have significant limitations: they don't work in many contexts (embedded browsers, some email clients), they can conflict with other apps, and they show error dialogs when the app isn't installed. Use HTTPS Universal Links and App Links as your primary deep linking mechanism.
7. Handle the cold start vs. warm start distinction. When the app is already running and receives a deep link, the behavior differs from a cold start. React Native's Linking.getInitialURL() only fires on cold start; the url event listener only fires on warm start. Flutter's go_router handles both, but if you're using manual platform channels, test both scenarios explicitly.
Conclusion
Cross-platform deep linking requires work on multiple layers: server-side verification files, native platform configuration, framework bridge code, and in-app navigation routing. No single framework abstracts all of this away, and that's unlikely to change because the native platform requirements are fundamental to how mobile operating systems handle links.
The practical approach is to standardize your URL patterns early, centralize your routing logic, type your route parameters, and test on real devices. For deferred deep links, you'll almost certainly want a service that handles the server-side matching rather than building it yourself.
Whether you're building with React Native, Flutter, Kotlin Multiplatform, or any other cross-platform framework, the deep linking fundamentals are the same. The differences are in the syntax, not the architecture. Get the architecture right, and switching frameworks or adding new platforms becomes a matter of writing new bridge code, not redesigning your link infrastructure.
Get deep linking tips in your inbox
One email per week. No spam.