Deep Linking with React Navigation v6: Mapping URLs to Screens Without the Guesswork
A practical walkthrough of React Navigation v6 deep linking: the linking config, native setup on Android and iOS, a worked article/:id example, and the testing pitfalls that break most setups.
04 Dec 2025, 13:00 UTC

You ship a React Native app, marketing sends out an email campaign with links like myapp://article/42, and half the taps dump users on the home screen instead of the article. The fix isn't more navigation code — it's telling React Navigation how URLs map to screens once, at the root, and letting its linking subsystem do the dispatching.
The useful takeaway: in React Navigation v6, a single linking object on NavigationContainer replaces hand‑rolled URL parsing. But it only works if your native configuration (Android intent filters, iOS URL schemes) is correct, and that part can't be tested in Expo Go.
How the linking config actually works
React Navigation's linking config is a bidirectional map: URL patterns in, navigation state out. When the OS hands your app a URL, the library parses it against your patterns, builds a navigation action, and merges path segments and query strings into route.params. The same config also works in reverse — getPathFromState can generate URLs from navigation state, which is handy for share features and web builds.
Two rules matter before you write any code:
- The config lives on the root
NavigationContainer. Nested navigators are described inside the same config via ascreenstree; a child navigator can't define its own independent linking. - Pattern syntax is deliberately simple:
article/:idfor params,*for wildcards. If you need exotic parsing, you write a customgetStateFromPathrather than fighting the pattern language.
A worked example: article links with a query param
Assume a stack navigator with Home and Article screens, and links of the form myapp://article/42?source=email. The config looks like this:
import { NavigationContainer } from '@react-navigation/native';\n\nconst linking = {\n prefixes: ['myapp://', 'https://blog.example.com'],\n config: {\n screens: {\n Home: '',\n Article: {\n path: 'article/:id',\n parse: {\n id: (id) => Number(id),\n },\n },\n NotFound: '*',\n },\n },\n};\n\nfunction App() {\n return (\n <NavigationContainer linking={linking} fallback={<Loading />}>\n <RootStack />\n </NavigationContainer>\n );\n}Three details worth noting. First, parse coerces the id segment to a number before it lands in route.params — without it, everything arrives as a string. Second, source=email needs no declaration; query params are merged into params automatically. Third, the NotFound: '*' wildcard catches malformed links instead of silently doing nothing. The fallback prop renders while the initial deep link is being resolved, which avoids a flash of the home screen on cold starts.
In the Article screen, verify the contract:
function ArticleScreen({ route }) {\n const { id, source } = route.params;\n // id is a number here because of the parse function\n}The native half, where most setups break
The JavaScript config is the easy part. The OS has to route the URL to your app first.
On Android, add an intent filter to your main activity in AndroidManifest.xml (requires a clean rebuild after editing):
<intent-filter>\n <action android:name=\"android.intent.action.VIEW\" />\n <category android:name=\"android.intent.category.DEFAULT\" />\n <category android:name=\"android.intent.category.BROWSABLE\" />\n <data android:scheme=\"myapp\" />\n</intent-filter>On iOS, register the scheme under CFBundleURLTypes in Info.plist. For universal links (https:// URLs that open the app without a browser flash), you additionally need Associated Domains entitlements and an apple-app-site-association file served from your domain — that's a server‑side dependency worth flagging to your backend team early.
Test on real builds, not Expo Go. On an Android emulator or device with adb:
adb shell am start -W -a android.intent.action.VIEW \\
-d \"myapp://article/42?source=email\" com.example.myappOn the iOS simulator:
xcrun simctl openurl booted \"myapp://article/42?source=email\"Run these from your development machine; no special app permissions are needed, but the app must be installed. The expected result is the app foregrounding directly on the Article screen with id === 42. If the app opens to Home instead, the native side is working but your pattern doesn't match — log the URL with a subscribe listener on the linking config to see what React Navigation actually received.
Trade‑offs and sharp edges
The declarative config covers most apps, but be honest about its limits. Custom schemes (myapp://) fail silently when the app isn't installed — the user just sees nothing happen — which is why production campaigns should prefer universal links / Android App Links despite the extra setup. Deep‑link‑driven navigation also bypasses your normal screen guards: if Article requires auth, you must check inside the screen or in a custom getStateFromPath, because the linking layer will happily navigate an anonymous user straight there. Finally, behavior differs between dev clients and standalone binaries, so treat a passing simulator test as necessary but not sufficient.
What to do next
Start with one route end‑to‑end: add the intent filter and URL scheme, wire a minimal linking config with a parse function and a * catch‑all, then drive it with the adb and simctl commands above. Once route.params shows the values you expect, expand the screens tree. Verifying the plumbing with a single link before mapping twenty screens is what separates a one‑hour task from a week of debugging.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.