from the app-site association to tested navigation
Custom URL schemes can be claimed by any app on the device, which opens the door to hijacking. iOS Universal Links and Android App Links solve this with a cryptographically verified domain, but they demand correct configuration on both the server and the app side. This article walks through setting up React Native deep linking properly, wiring it into React Navigation, and testing it reliably.
Table of Contents
- 1. What deep linking and Universal Links actually solve
- 2. The React Native Linking API
- 3. Setting up iOS Universal Links
- 4. Setting up Android App Links
- 5. Deep linking with React Navigation
- 6. Testing deep links
- 7. Expo-specific configuration
- 8. Fallback strategies and edge cases
- 9. Deep linking approaches compared
- 10. Summary
- 11. FAQ
1. What deep linking and Universal Links actually solve
The classic approach to React Native deep linking is a custom URL scheme like myapp://profile/42. The problem: a custom scheme is not exclusively reserved. Any app on the device can register for the same scheme, and which app actually opens the link depends on install order and OS version. For sensitive flows like login confirmations or payment callbacks, that is a real security risk, since a malicious app could intercept the link.
Universal Links on iOS and App Links on Android solve this by binding the link to a real domain verified over HTTPS. When a link is opened, the operating system cryptographically checks whether the app was actually authorized by the domain owner before letting it open the link instead of the browser. This verification happens server side via a publicly reachable configuration file, which we build in the sections below.
The practical benefit goes beyond security: a Universal Link is simultaneously a normal, clickable HTTPS URL. It works in emails, messenger apps, and search results just like in a browser, whereas a custom scheme is often not even recognized as a link in many contexts, such as an email preview. For any deep linking concept that goes beyond internal debug links, the domain-based approach is therefore the right starting point.
2. The React Native Linking API
Regardless of whether Universal Links, App Links, or a custom scheme end up being used, React Native processes them through the same Linking API. Two cases must be distinguished: if the app is launched fresh by the link (cold start), Linking.getInitialURL() returns the URL the app was opened with. If the app is already running in the background and gets brought to the foreground by a link (warm start), the 'url' event fires instead, handled with Linking.addEventListener('url', handler).
A common mistake in React Native deep linking is handling only one of these two cases. Anyone who listens only for the event misses every link the app was freshly launched with, because the event has already fired before the listener could be registered. The robust solution explicitly checks getInitialURL() at app start and registers the event listener in parallel for every subsequent link during runtime.
When using React Navigation, you rarely have to wire these two cases manually, since the linking prop of NavigationContainer encapsulates exactly this behavior internally. Still, understanding the underlying API pays off, because custom analytics events or edge cases like deferred deep linking often access Linking directly rather than the navigation layer.
// LinkingHandler.js — cold start + warm start deep link handling
import { useEffect } from 'react';
import { Linking } from 'react-native';
import { useNavigation } from '@react-navigation/native';
export function useDeepLinkHandler() {
const navigation = useNavigation();
useEffect(() => {
// Cold start: app was launched directly via a link
Linking.getInitialURL().then((url) => {
if (url) handleDeepLink(url, navigation);
});
// Warm start: app was already running in the background
const subscription = Linking.addEventListener('url', ({ url }) => {
handleDeepLink(url, navigation);
});
return () => subscription.remove();
}, [navigation]);
}
function handleDeepLink(url, navigation) {
const route = parseDeepLinkUrl(url);
if (route) navigation.navigate(route.name, route.params);
}
// Matches config.screens mapping used by NavigationContainer's linking prop
const linkingConfig = {
prefixes: ['https://mironsoft.de', 'react-native-demo://'],
config: {
screens: {
Profile: 'profile/:userId',
OrderDetails: 'order/:orderId',
Home: '',
},
},
};
export { linkingConfig };
3. Setting up iOS Universal Links
On iOS, setup starts with the Associated Domains capability in the Xcode project, where the domain is entered as applinks:mironsoft.de. This capability is what allows the app to register for Universal Links on that domain in the first place. Without this entry, iOS ignores any verification file on the server entirely, a common reason Universal Links fail to work despite correct server configuration.
On the server side, a JSON file must be reachable at /.well-known/apple-app-site-association, without a file extension, containing the app's team ID and bundle ID along with the allowed path patterns. Crucially, the file must be served over HTTPS without any redirect, with the content type application/json, since iOS fetches and caches exactly this file on first app launch, and periodically afterward. A redirect chain, say from HTTP to HTTPS, makes verification fail silently.
A second typical pitfall: the file is often edited afterward, but iOS caches the verification result aggressively. Changes are reliably picked up only after a reinstall of the app or after a few days. For development, it therefore pays to test verification early with a stable domain setup rather than changing the file multiple times a day during development.
{
"applinks": {
"details": [
{
"appIDs": ["<TEAM_ID>.de.mironsoft.app"],
"components": [
{ "/": "/profile/*", "comment": "Matches user profile deep links" },
{ "/": "/order/*", "comment": "Matches order detail deep links" }
]
}
]
}
}
4. Setting up Android App Links
The Android counterpart to apple-app-site-association is the assetlinks.json file, also hosted under /.well-known/. It contains the SHA256 fingerprint of the signing key the app was signed with, along with the package ID. Android checks this fingerprint against the actually installed APK, so only the exact matching, correctly signed app passes verification. When using Google Play App Signing, the fingerprint of the Play signing key must be used here, not the upload key.
In AndroidManifest.xml, an intent-filter with android:autoVerify="true" must additionally be defined that references the domain and scheme. The autoVerify flag automatically triggers the check against assetlinks.json when the app is installed. If this flag is missing or the check fails, Android still opens the link in the browser instead of the app, even if the intent filter is technically configured correctly.
In practice, it pays to manually verify App Link verification after every release rather than relying solely on automatic behavior. A failed React Native deep linking setup on Android almost always shows up only during real device testing, because emulators sometimes handle domain verification differently than physical devices.
# AndroidManifest.xml — intent filter for verified App Links
cat > /tmp/intent-filter-snippet.xml <<'EOF'
<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="mironsoft.de" />
</intent-filter>
EOF
# Verify domain association status on a connected device
adb shell pm get-app-links de.mironsoft.app
# Force re-verification after updating assetlinks.json
adb shell pm verify-app-links --re-verify de.mironsoft.app
5. Deep linking with React Navigation
React Navigation provides a declarative layer over the raw Linking API through the linking prop of NavigationContainer. Via config.screens, every screen name is mapped to a URL path pattern, for example Profile: 'profile/:userId'. React Navigation then automatically handles parsing the URL, extracting parameters, and navigating to the matching screen, including both the cold start and warm start cases.
For nested navigators, say a stack inside a tab navigator inside another stack, the nesting is mirrored in the screens configuration: each level gets its own screens object, and React Navigation resolves the path recursively from outside in. This allows deeply nested destinations, like a specific tab with a specific detail screen, to be reached directly through a single link, without manually rebuilding the navigation hierarchy.
Parameters from the path, such as userId from profile/:userId, automatically land as the route.params.userId prop on the target screen, identical to a normal programmatic navigation with navigation.navigate('Profile', { userId: '42' }). This means screens need no special handling for deep link invocations as long as they read their parameters consistently through route.params anyway.
6. Testing deep links
Deep links can be tested on iOS in the simulator without real network access to the production domain, using the command xcrun simctl openurl booted "https://mironsoft.de/profile/42". The simulator opens the link exactly as a real device would after successful Universal Link verification, provided the app was built with the correct Associated Domains capability. If that fails and the link opens Safari instead, the error almost always lies in the apple-app-site-association configuration.
On Android, the command adb shell am start -W -a android.intent.action.VIEW -d "https://mironsoft.de/profile/42" de.mironsoft.app tests the same case, where the -W flag additionally prints the time until launch, which is helpful for performance analysis. Combining an explicit package name with the intent action ensures the test really opens the app and does not accidentally end up in a chooser dialog with multiple apps.
For the actual verification file, online validators help check the JSON structure and content type of the served file, along with the Xcode console log at app start, which prints explicit error messages about Universal Link verification. The most common validation error is an incorrect content type, often text/plain instead of application/json, because many static hosting setups misclassify extensionless files.
7. Expo-specific configuration
In the Expo managed workflow, the basic custom scheme is defined via the scheme field in app.json, while Universal Links and App Links are configured through the ios.associatedDomains and android.intentFilters blocks respectively. These settings automatically flow into the generated native projects on the next eas build, without manually editing entitlements or AndroidManifest.xml.
The practical difference from the bare workflow: changes to associatedDomains require a new native build, since Expo Go, being a generic test app, cannot perform app-specific domain verification. For local development with Universal Links, a development build via expo-dev-client is therefore necessary, whereas simple custom scheme links also work in Expo Go.
Anyone using Expo Router directly instead of React Navigation additionally benefits from the fact that the filesystem-based route structure automatically produces a matching deep linking schema: a screen at app/profile/[userId].tsx is already reachable via profile/:userId without additional configuration, which makes maintaining a separate linking configuration unnecessary.
8. Fallback strategies and edge cases
If the app is not installed when a Universal Link is opened, iOS automatically falls back to opening the underlying website in the browser, since a Universal Link is always also a valid, standalone HTTPS URL. That website should therefore offer identical content or at least a sensible redirect instead of an error page, since a meaningful share of users may not have the app installed yet.
For deferred deep linking, that is, remembering the original target URL across an app store detour, the native Universal Link mechanism alone is not enough, because the app store itself does not pass through any parameters. This is where additional attribution services usually come in, caching the target URL server side and re-associating it after installation via device fingerprinting or clipboard handoff.
Behavior can also become briefly inconsistent during an app update if the supported path patterns change between versions. The apple-app-site-association and assetlinks.json configuration should therefore stay as backward compatible as possible, and a custom scheme should be added as a last fallback in React Navigation's prefixes list in case domain verification fails for any reason.
9. Deep linking approaches compared
The choice between custom scheme, Universal Links, and App Links is not merely a matter of taste, it directly affects security and user experience. The following overview summarizes the key differences for React Native deep linking.
| Approach | Security | Behavior without the app | Setup effort |
|---|---|---|---|
| Custom URL Scheme | Not exclusive, hijacking possible | Error, no fallback | Low |
| iOS Universal Links | Domain verified | Opens website | Medium |
| Android App Links | Domain and key verified | Opens website | Medium |
In practice, a combination works best: Universal Links and App Links as the primary, verified mechanism, complemented by a custom scheme as a last fallback for edge cases like internal test builds without a publicly reachable domain. This combination covers both production React Native deep linking flows and development scenarios without sacrificing security.
Mironsoft
React Native development for iOS and Android
Deep linking that actually works in production?
We set up Universal Links and App Links for your React Native app, wire them cleanly into React Navigation, and make sure testing is reliable on real devices, not just in the simulator.
Domain verification
Hosting and validating apple-app-site-association and assetlinks.json correctly
Navigation integration
React Navigation linking configuration, including nested screens
Testing & monitoring
Device testing, fallback strategies, and deferred deep linking concepts
10. Summary
React Native deep linking via Universal Links and App Links solves the security problem of classic custom schemes by tying verification to a real domain checked over HTTPS. The central file on iOS is apple-app-site-association, on Android assetlinks.json, both hosted under /.well-known/ and reachable without any redirect. The Associated Domains capability and the autoVerify intent filter are the respective app-side counterparts, without which the server configuration remains ineffective.
React Navigation encapsulates the actual navigation through the linking prop, including the cold start and warm start case and nested screens. Testing is reliable via xcrun simctl openurl and adb shell am start, while real device testing before every release remains mandatory, because domain verification does not always behave identically on emulators. A custom scheme as a last fallback rounds out a robust deep linking setup without giving up the security benefits of the domain-based approaches.
React Native Deep Linking and Universal Links — the essentials at a glance
Domain verification
apple-app-site-association and assetlinks.json under /.well-known/, no redirect, correct content type.
App-side configuration
Associated Domains capability (iOS) and android:autoVerify="true" intent filter (Android) are mandatory.
React Navigation
The linking prop declaratively encapsulates cold start, warm start, and nested screens.
Testing
xcrun simctl openurl and adb shell am start for simulator/emulator, test real devices before every release.