Deep links are one of those features that works perfectly in dev and breaks in production in the weirdest ways. The link opens Safari instead of the app. The app opens but lands on the home screen instead of the right route. The query params vanish on cold start. The link works from email but not from Slack.
The root cause is always one of three things: the association file is misconfigured, the navigation config does not match the URL structure, or the cold-start race condition is not handled. Here is a setup that handles all three.
The two flavors of deep link
Custom URL schemes (myapp://screen) are the old way. They work but any app can claim any scheme. If two apps claim myapp://, behavior is undefined. Avoid them for production.
Universal Links (iOS) and App Links (Android) are the modern way. The OS verifies ownership of your domain through a hosted file, then opens your app directly. If the app is not installed, the link opens in the browser. No prompts, no ambiguity.
Use Universal Links and App Links. The rest of this guide assumes them.
Step 1: Host the association files
For iOS, host apple-app-site-association at:
https://yourdomain.com/.well-known/apple-app-site-association
The file looks like:
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.yourcompany.yourapp"],
"components": [
{ "/": "/products/*" },
{ "/": "/profile/*" },
{ "/": "/*", "exclude": true }
]
}
]
}
}Important:
- The file MUST be served over HTTPS with a valid certificate.
- The content-type header MUST be
application/json. Some servers default to plaintext and iOS silently rejects it. - The path is
.well-known/apple-app-site-associationwith no.jsonextension.
For Android, host assetlinks.json at:
https://yourdomain.com/.well-known/assetlinks.json
[
{
"relation": ["delegate_permission/common.handle_login"],
"target": {
"namespace": "android_app",
"package_name": "com.yourcompany.yourapp",
"sha256_cert_fingerprints": [
"AB:CD:EF:..."
]
}
}
]Get the SHA-256 fingerprint with:
keytool -list -v -keystore your-upload-key.jks -alias uploadStep 2: Configure React Navigation
Use the linking prop on your NavigationContainer:
import { NavigationContainer } from '@react-navigation/native'
const linking = {
prefixes: ['https://yourdomain.com', 'myapp://'],
config: {
screens: {
Home: '',
Product: {
path: 'products/:id',
parse: {
id: (id: string) => decodeURIComponent(id),
},
},
Profile: 'profile/:userId',
NotFound: '*',
},
},
}
function App() {
return (
<NavigationContainer linking={linking}>
<RootStack />
</NavigationContainer>
)
}The prefixes list includes both the production domain and the custom scheme (as a fallback for older share links). The config maps screen names to URL paths. parse lets you transform path params.
Step 3: Handle the cold start race
On cold start, here is what happens:
- iOS/Android opens the app and passes the deep link URL.
- React Native boots.
- Your auth check runs (probably async).
- The navigation container mounts.
- The deep link event fires.
The problem: step 5 can fire before step 3 finishes. The deep link tries to navigate to Profile, but Profile requires a logged-in user, so the auth guard redirects to Login and the deep link is lost.
Fix it by gating the deep link handler:
function App() {
const [isReady, setIsReady] = useState(false)
const [initialUrl, setInitialUrl] = useState<string | null>(null)
useEffect(() => {
(async () => {
const session = await loadSession()
setSession(session)
const url = await Linking.getInitialURL()
setInitialUrl(url)
setIsReady(true)
})()
}, [])
if (!isReady) return <SplashScreen />
return (
<NavigationContainer linking={linking}>
<RootStack initialUrl={initialUrl} session={session} />
</NavigationContainer>
)
}Now the deep link URL is captured after the session loads. Your auth guard can decide whether to honor it, save it for after login, or discard it.
Step 4: Handle foreground deep links
When the app is already running and a deep link opens it, use a Linking.addEventListener listener:
useEffect(() => {
const subscription = Linking.addEventListener('url', ({ url }) => {
// Manually navigate using the linking config
if (navigationRef.isReady()) {
navigationRef.navigate(parseUrl(url))
}
})
return () => subscription.remove()
}, [])Common bug: forgetting this listener. Cold-start deep links work but foreground ones silently fail. Test both paths.
Step 5: Test on real devices
Universal Links and App Links do not work reliably in iOS Simulator or Android Emulator. You need real devices.
Test recipe:
- Send the link to yourself via email or SMS.
- Tap it from outside the app (mail client, messages, notes).
- Confirm the app opens to the correct screen.
- Kill the app. Tap the link again. Confirm cold-start works.
- Open the app. Tap the link from another app. Confirm foreground handling works.
For Android, you can also test in the emulator with:
adb shell am start -W -a android.intent.action.VIEW -d "https://yourdomain.com/products/123" com.yourcompany.yourappCommon pitfalls
apple-app-site-associationserved with the wrong content-type — Apple rejects it silently. Useapplication/json.- Mixed
wwwand apex — the association file must be hosted on bothwww.yourdomain.comandyourdomain.comif your links use both hosts. Or pick one and 301 the other. - Signing fingerprint mismatch — Android debug and release builds have different fingerprints. Add both to
assetlinks.jsonfor development. - Marketing redirects that strip the path — if
yourdomain.com/promoredirects toyourdomain.com/products/123, the OS seespromoand tries to match it. Use the final URL in your marketing materials. - Query params not in the route config — React Navigation ignores unknown query params. They are available through the parse function or the useRoute hook, but only if you read them.
Deep linking is two parts configuration, one part race-condition handling. The configuration is documented. The race condition is not. Test cold start on real devices every release.
Need help with deep linking?
I have shipped deep linking for production React Native apps in e-commerce, social, and SaaS. Let's talk.
Frequently Asked Questions
Why is my React Native deep link opening the App Store instead of my app?
Your Universal Links configuration is incomplete. Check that your apple-app-site-association file is hosted at the correct path with the right content-type header, your app's team ID and bundle ID are listed correctly, and the file is signed. iOS falls back to the App Store silently if verification fails.
How do I get deep link query parameters in React Navigation?
Use the parse function in your linking config to extract params from the URL. React Navigation passes them to your screen as route.params. For complex cases, read the full path from the route and parse it manually with the URL API.
Do React Native deep links work in the simulator?
Universal Links and App Links do not work reliably in iOS Simulator or Android Emulator. Test on real devices by sending the link via email or SMS and tapping from outside the app. For Android, you can use adb shell am start to test URL schemes in the emulator.
What is the difference between URL schemes and Universal Links?
Custom URL schemes (myapp://) are simple but easily hijacked — any app can claim them. Universal Links and App Links are verified by the OS through a server file, so only your app can open them. Use Universal Links on iOS and App Links on Android for production apps.