Deep linking is one of those features that looks like a one-afternoon task in the estimate and turns into a two-week bug hunt in the sprint. The demo works on the simulator, then marketing sends a campaign and half the recipients land on the App Store page instead of the promo screen, iOS users who tapped a link inside Gmail get bounced to Safari, and a password reset link opens the app to a blank Home tab.
None of that is mysterious once you understand that "deep linking" is really four separate systems wearing one name: custom URL schemes, iOS Universal Links, Android App Links, and deferred (post-install) attribution. This tutorial walks through all four for a React Native app, with the Expo Router linking setup we use on client projects.
The four mechanisms, and when each one fires
| Mechanism | Example | Works if app not installed? | Typical use |
|---|---|---|---|
| Custom scheme | myapp://promo/42 | No (nothing happens) | OAuth callbacks, in-app navigation, dev testing |
| iOS Universal Link | https://example.com/promo/42 | Yes - opens the web page | Email, SMS, share sheets, web-to-app |
| Android App Link | https://example.com/promo/42 | Yes - opens the web page | Same as above |
| Deferred deep link | Link -> store -> install -> route | Yes, by design | Paid acquisition, referral flows |
The rule of thumb we give clients: use HTTPS links everywhere a human will see the URL, and keep the custom scheme for OAuth redirects and internal testing. A custom scheme that a user taps without the app installed is a dead end, and Gmail, Slack, and iMessage all treat unknown schemes inconsistently.
Step 1: declare the scheme and the domains
In an Expo / CNG project, this is all config - no Xcode or Android Studio edits:
{
"expo": {
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.example.myapp",
"associatedDomains": ["applinks:example.com", "applinks:www.example.com"]
},
"android": {
"package": "com.example.myapp",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "example.com", "pathPrefix": "/" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
Two traps here. First, applinks:example.com does not cover www.example.com - subdomains must be listed explicitly (or you use a wildcard entry, which costs you an extra association fetch). Second, autoVerify: true on Android is what turns a "which app do you want to use?" chooser into a silent, direct open. Without it you technically have a working link and a terrible experience.
For bare React Native projects the same two things live in the Associated Domains capability in Xcode and in the <intent-filter android:autoVerify="true"> block of AndroidManifest.xml.
Step 2: host the association files correctly
This is where most "it doesn't work in production" tickets actually live. Both platforms fetch a JSON file from your domain over HTTPS, with no redirects, with Content-Type: application/json, and with no authentication.
https://example.com/.well-known/apple-app-site-association (no .json extension):
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.example.myapp"],
"components": [
{ "/": "/promo/*", "comment": "promo screens" },
{ "/": "/reset/*", "comment": "password reset" },
{ "/": "/admin/*", "exclude": true, "comment": "stay on web" }
]
}
]
}
}
https://example.com/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": ["AA:BB:...:FF"]
}
}
]
Checklist we run before blaming the app code:
- Does
curl -sIL https://example.com/.well-known/assetlinks.jsonreturn a single200with no 301 hop? A redirect from apex towwwsilently breaks iOS. - Is the SHA-256 fingerprint the one from the signing key Google Play actually uses? With Play App Signing, the upload key fingerprint is not the one that ships. Copy it from Play Console -> Setup -> App integrity.
- Are all variants listed - debug, internal, and production packages have different fingerprints and can all go in the array.
- On Android, verify on a device:
adb shell pm get-app-links com.example.myappshould printverifiedper host.noneorlegacy_failuremeans the file or fingerprint is wrong. - On iOS, delete and reinstall after changing the AASA file. The CDN-cached association is fetched at install time; simply relaunching will not pick it up.
Step 3: map URLs to screens
With Expo Router, the file system already is the linking config, which removes an entire class of "the URL parsed but the route didn't exist" bugs. A file at app/promo/[id].tsx answers https://example.com/promo/42 as long as the association file allows it.
If you are on React Navigation without the router, you still write the config by hand, and it must be kept in sync with the navigator tree:
const linking = {
prefixes: ['myapp://', 'https://example.com', 'https://www.example.com'],
config: {
screens: {
Tabs: {
screens: {
Home: '',
Promo: 'promo/:id',
},
},
ResetPassword: 'reset/:token',
NotFound: '*',
},
},
};
Always define a NotFound catch-all. Links outlive app versions: a campaign from last quarter will point at a path that a refactor removed, and the difference between a graceful "this offer has ended" screen and a white screen is one route entry.
Step 4: cold start vs warm start
A warm app receives the URL through an event. A cold app has to ask for the URL that launched it. Miss the second case and your link works perfectly in testing (app already running) and fails for real users (app killed).
import * as Linking from 'expo-linking';
import { useEffect } from 'react';
export function useIncomingLinks(handle: (url: string) => void) {
useEffect(() => {
let cancelled = false;
// Cold start: the URL that launched the process.
Linking.getInitialURL().then((url) => {
if (url && !cancelled) handle(url);
});
// Warm start: app already in memory.
const sub = Linking.addEventListener('url', ({ url }) => handle(url));
return () => {
cancelled = true;
sub.remove();
};
}, [handle]);
}
Expo Router wires both paths for you, but the same rule applies to anything you layer on top - analytics, paywalls, or a custom router. And keep the splash screen visible until you know where you are navigating; hiding it, rendering Home, then jumping to the deep-linked screen produces a visible flash that reviewers and clients both notice.
Step 5: the auth-gated link problem
The single most common deep link bug in production is: link arrives, user is logged out, app shows Login, user logs in, and lands on Home. The destination was thrown away.
The fix is to treat the incoming URL as pending intent rather than as an immediate navigation:
// Stash before the auth gate redirects.
await AsyncStorage.setItem('pendingDeepLink', url);
// After a successful sign-in:
const pending = await AsyncStorage.getItem('pendingDeepLink');
if (pending) {
await AsyncStorage.removeItem('pendingDeepLink');
router.replace(pending);
}
Two guardrails worth adding: expire the stored intent after, say, an hour so a stale link does not hijack a later session, and validate the path against an allowlist of routes before navigating. Treat an inbound URL as untrusted input - it can come from any app on the device. Never let a deep link carry a session token, an email address you act on without verification, or a raw URL that you feed into a WebView.
Step 6: deferred deep links, honestly
If the app is not installed, the HTTPS link opens your website. To route the user to the right screen after they install, someone has to remember the intent across the store visit. On iOS, ATT and the privacy rules mean that the fingerprinting tricks the old link services used are unreliable and, in several cases, disallowed. The options that survive:
- Apple App Clips / Android Instant Apps for a tightly scoped flow - no attribution needed, the experience just runs.
- A dedicated attribution SDK (Branch, AppsFlyer, Adjust) if you are spending on paid acquisition and need install attribution anyway. Budget for a privacy-manifest and consent review; these SDKs collect data and your store submission has to declare it.
- A code on the landing page - "open the app and enter PROMO42" - which sounds low-tech and converts surprisingly well for low-volume campaigns without adding an SDK or a data-sharing disclosure.
We steer clients away from adding a full attribution SDK unless there is a real marketing budget behind it. It is a recurring cost in compliance surface, review time, and app size.
Step 7: test it like a user, not like a developer
npx uri-scheme open https://example.com/promo/42 --ios and adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/promo/42" are useful for route wiring, but they bypass the association check entirely - they will happily "work" while real links are broken.
The real test matrix:
- Paste the link into Apple Notes and iMessage, then tap it. (Notes is the classic quick check on iOS.)
- Tap it inside Gmail and inside Slack - in-app browsers are where universal links most often get swallowed.
- Test with the app force-quit, backgrounded, and foregrounded.
- Test logged out and logged in.
- Test on a device where the app was never installed, then install and re-tap.
- Add one end-to-end test in Maestro that launches with a URL, so a route rename fails CI instead of a campaign.
# maestro/deep-link.yaml
appId: com.example.myapp
---
- launchApp:
clearState: true
- openLink: https://example.com/promo/42
- assertVisible: "Spring Promo"
What good looks like
When deep linking is done, you should be able to say: every link marketing, support, or the product team can generate resolves to a real screen; a logged-out user reaches that screen after signing in; a killed app reaches it on cold start without a flash of Home; unknown paths land on a friendly fallback; and CI fails if someone renames a route that a live link depends on.
That is a couple of days of work on a healthy codebase and a week on one where the navigator grew organically. It is almost always worth doing before the campaign, not after.
Fighting links that open the wrong screen, or planning a web-to-app funnel for a React Native app? Get in touch - our senior React Native consultants do this kind of audit and implementation as a short, scoped engagement.