Deep Linking in React Native 2026: Universal Links, App Links, and the Edge Cases That Bite
Deep linking looks trivial until a marketing campaign goes live and half your users land on the App Store instead of the screen you promised. Here's what actually works in 2026.
Deep linking looks trivial in a hello-world demo. Then marketing ships a campaign, a user taps a link from Gmail on an iPhone, ends up at the App Store with no context, and you spend a Friday night reading Apple's apple-app-site-association docs again. This is the guide we wish we had on hand the last three times we shipped a React Native app with serious linking requirements.
Why deep linking is still hard in 2026
The underlying primitives — Universal Links on iOS and App Links on Android — haven't changed dramatically since 2020. What has changed is the surface area around them: Expo Router has matured, the New Architecture is on by default for new apps, in-app browsers behave differently across iOS 17/18/19, and email clients increasingly strip or wrap URLs through tracking redirectors.
The failure modes are almost never "the code is wrong". They're configuration drift, association file caching, or a tracker domain in the middle of the redirect chain that breaks the OS's domain-to-app mapping. If you only remember one thing from this post: deep linking is 80% server-side configuration, 20% app code.
The three flavors you actually need to handle
Before writing a line of code, get clear on which of these you support, because the UX expectations differ:
- Custom scheme links (
myapp://product/123) — only work if the app is already installed and the link is tapped from a context that allows scheme handoff. Useful for internal flows, OAuth callbacks, and QR codes. - Universal Links / App Links (
https://yourdomain.com/product/123) — the OS opens the app if installed, otherwise the link falls through to your website. This is what marketing wants. - Deferred deep linking — user taps a link, doesn't have the app, installs it, and lands on the originally intended screen on first launch. The OS does not give you this for free. You need a third party (AppsFlyer, Adjust, Branch, or a homegrown fingerprint-based fallback) or a smart web landing page.
If you confuse these in a planning meeting, you will ship the wrong thing.
Setting it up with Expo Router
Expo Router collapses a lot of the boilerplate. Your app.json (or app.config.ts) does most of the heavy lifting:
{
"expo": {
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.acme.myapp",
"associatedDomains": ["applinks:acme.com", "applinks:www.acme.com"]
},
"android": {
"package": "com.acme.myapp",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{ "scheme": "https", "host": "acme.com" },
{ "scheme": "https", "host": "www.acme.com" }
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
With file-based routing, a URL like https://acme.com/product/123 will route to app/product/[id].tsx automatically — provided the OS handed the URL to your app in the first place. That handoff is the part people get wrong.
The two files that decide your fate
On iOS, Apple fetches https://acme.com/.well-known/apple-app-site-association (AASA). On Android, Google fetches https://acme.com/.well-known/assetlinks.json. Both must be:
- Served over HTTPS with a valid certificate
Content-Type: application/json- Not behind a redirect (especially not a 301 from apex to www, or vice versa — serve it on both)
- Reachable without authentication, cookies, or geo-blocking
A minimal AASA for one app:
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID123.com.acme.myapp"],
"components": [
{ "/": "/product/*", "comment": "product pages" },
{ "/": "/u/*", "comment": "user profiles" },
{ "/": "/_next/*", "exclude": true }
]
}
]
}
}
Note the exclude rule. If you don't exclude framework asset paths, the OS may intercept them and your web fallback breaks for users who have the app installed.
For Android, generate assetlinks.json using your release signing fingerprint and your Play App Signing fingerprint. They are usually different. Forgetting the Play upload key fingerprint is the single most common reason Android App Links "silently don't work" after a release.
Reading the link inside the app
With Expo Router, you rarely need to manually parse URLs — the router handles it. But for cold-start cases, analytics, and conditional routing, you'll still touch the linking API:
import { useEffect } from 'react';
import * as Linking from 'expo-linking';
import { router } from 'expo-router';
export function useDeepLinkBootstrap() {
useEffect(() => {
const handle = (url: string | null) => {
if (!url) return;
const { hostname, path, queryParams } = Linking.parse(url);
if (path?.startsWith('product/')) {
router.push({ pathname: path, params: queryParams ?? {} });
}
};
Linking.getInitialURL().then(handle);
const sub = Linking.addEventListener('url', ({ url }) => handle(url));
return () => sub.remove();
}, []);
}
Two things to watch:
- Cold start vs warm start.
getInitialURLfires once.addEventListenerfires when the app is resumed via a link. If you only listen to one, half your users get a broken experience. - Auth gating. Don't
router.pushto a gated screen before your auth state has rehydrated. Queue the intent and replay it after hydration finishes, or you'll bounce the user to the login screen and lose the target.
The edge cases that have burned us
Email clients and tracker domains
Marketing sends a campaign through a vendor that rewrites every URL to https://click.tracker.com/x/abc123 which 302s to https://acme.com/product/123. The OS sees the first domain, not the final one. Universal Links / App Links match on the originating domain, so the redirect chain isn't your app's domain and nothing opens.
Fix: either add the tracker domain to your associatedDomains (the vendor must serve an AASA for you — most don't), or have marketing use direct links and accept slightly worse attribution, or sit a thin redirector on your own subdomain (go.acme.com) that you control and is in your AASA.
In-app browsers
Links tapped inside Instagram, TikTok, or LinkedIn often open in a webview that never triggers a Universal Link. iOS only honors the handoff for top-level Safari navigation and a handful of system contexts. The mitigation is a "Open in App" button on your web landing page that uses your custom scheme as a fallback, with a short delay before showing a store link.
iOS Safari and the long-press
If a user long-presses a Universal Link and chooses "Open", iOS opens it in Safari, not your app. This is by design. Some users have trained themselves to do this. Document it for support and move on.
AASA caching
iOS caches AASA aggressively. Changes can take 24 hours or more to propagate on devices that already have the app installed. During development, reinstall the app to force a fresh fetch. In production, never assume an AASA change is "live" the instant you deploy it.
Android autoVerify failures
If assetlinks.json is unreachable at install time, Android marks your domain as unverified and silently downgrades App Links to a disambiguation dialog. You can check status with:
adb shell pm get-app-links com.acme.myapp
Look for verified next to each host. If it says legacy_failure or 1024, your file isn't being fetched correctly.
Native Swift / Kotlin — when does it matter?
For 95% of deep linking work, React Native and Expo are equivalent to native. The OS does the routing; your app just reads the URL. Where native pulls ahead is Siri Shortcuts, App Clips (iOS), and Instant Apps (Android, though largely deprecated by 2026). If those are in your roadmap, plan native modules early rather than fighting JS-side wrappers.
For everything else — Universal Links, App Links, custom schemes, OAuth callbacks — Expo's linking primitives are mature and the failure modes are identical to native, because the bugs almost always live in your AASA file, not your app code.
A pre-launch checklist
Before you push deep linking to production, walk through this:
- AASA served on apex and www, no redirect, valid JSON, correct team ID
assetlinks.jsonincludes both upload and app signing fingerprintsgetInitialURLand theurlevent listener both wired up- Auth-gated routes queue the intent instead of dropping it
- Tested from: Safari, Chrome, Notes, Messages, Mail, Slack, Gmail, and at least one in-app browser
- A web fallback page with an "Open in App" affordance for in-app browser cases
- A monitoring event fired when a deep link arrives so you can see drop-off in analytics
Where we'd start
If you're adding deep linking to an existing Expo app, do the boring part first: stand up AASA and assetlinks.json, validate them with Apple's and Google's official validators, and test on a physical device with a fresh install. Only then touch app code. We've seen teams spend a sprint debugging Linking.addEventListener when the actual bug was a Content-Type: text/html header on their association file.
If you want a hand auditing a linking setup before a campaign goes out, our mobile team has done this enough times to know where the bodies are buried.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Expo Dev Client vs Expo Go in 2026: Stop Fighting Your Own Tooling
Expo Go is great until it isn't. Here's when to graduate to a custom dev client, what breaks during the switch, and how to keep your team productive while you migrate.

Background Tasks on React Native in 2026: What Actually Runs on iOS and Android
Background execution is the swampiest part of mobile. Here's what actually works on iOS and Android in 2026, what Expo gives you, and where you still need a native module.

React Native New Architecture in 2026: When to Migrate and When to Wait
The New Architecture is the default in fresh React Native projects, but legacy apps face a real migration cost. Here's how we decide when to pull the trigger and when to delay.
