Deep Links in React Native 2026: Universal Links, App Links, and the Expo Router Trap
Universal Links on iOS and App Links on Android look simple until you ship. Here's what actually breaks in a React Native + Expo Router app in 2026, and how we debug it without burning a release.
Deep links are one of those features that demo in five minutes and then eat two sprints when you actually ship. In 2026, with Expo Router stable and both platforms tightening their verification rules, the failure modes have shifted — and most of the Stack Overflow answers you'll find are stale.
This is a field guide for teams building production React Native apps that need real deep linking: Universal Links on iOS, App Links on Android, and the Expo Router routing layer sitting on top of both.
What "deep linking" actually means in 2026
There are three distinct things people call deep links, and conflating them is where most bugs start:
- Custom scheme links —
myapp://product/123. Still work, still useful for OAuth callbacks and dev builds, but browsers won't open them from a cold tap in Safari or Chrome. - Universal Links (iOS) / App Links (Android) — real HTTPS URLs like
https://shop.example.com/product/123that open your app if installed, and fall back to the web if not. - Deferred deep links — the URL survives an install from the store. Neither Apple nor Google ships this natively; you need a third party (Branch, Adjust, AppsFlyer) or a cookie-based handshake on your own web property.
In our experience, teams get #1 working, assume #2 is "the same thing with extra config," and discover six weeks later that #3 is a product decision, not a config file.
The iOS side: AASA is stricter than it used to be
Apple's apple-app-site-association file has been around forever, but a few things have tightened:
- It must be served at
https://yourdomain.com/.well-known/apple-app-site-associationwithContent-Type: application/json, no redirects, no auth. - The CDN edge cache matters. If you ship a new AASA and Apple's CDN has the old one, you can wait up to 24 hours for the swizzle. Add a cache-busting header on deploy.
- As of recent iOS versions, Apple fetches AASA through its own CDN proxy (
app-site-association.cdn-apple.com). Firewall rules that block non-Apple user agents to your origin will silently break verification.
A minimal AASA for an Expo app looks like this:
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.yourco.yourapp"],
"components": [
{ "/": "/product/*", "comment": "product pages" },
{ "/": "/u/*", "comment": "user profiles" },
{ "/": "/auth/*", "exclude": true }
]
}
]
}
}
Debugging when the link opens Safari instead of your app
This is the single most common bug ticket. Run through this list before you blame Expo:
- Did the user long-press the link and tap "Open in Safari"? iOS remembers that choice per domain. Reset by long-pressing again and picking "Open in YourApp."
- Is the link coming from inside another app's in-app browser (Instagram, Gmail)? Those use
SFSafariViewControlleror custom webviews and won't trigger Universal Links. There's no fix on your side — only education or an interstitial page. - Did you install the app from TestFlight? AASA is only re-fetched on install or app update. Delete and reinstall after changing associated domains.
- Run
swcutil dlon a tethered device (requires a developer-mode Mac) to see exactly what iOS thinks about your domain.
The Android side: assetlinks and the Play Store signing problem
Android App Links are gated on a assetlinks.json file at https://yourdomain.com/.well-known/assetlinks.json plus a android:autoVerify="true" intent filter in your manifest.
The part that trips up almost every team: the SHA-256 fingerprint in assetlinks must match the key that actually signed the APK the user installed. If you use Play App Signing (and you should), that's the Google-managed upload key, not your local keystore. Find it under Play Console → Setup → App signing.
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourco.yourapp",
"sha256_cert_fingerprints": [
"AA:BB:CC:...", // Play App Signing cert
"11:22:33:..." // local debug keystore, for dev builds
]
}
}]
List both production and debug fingerprints. Otherwise your QA team on internal builds will tell you links are broken while production works fine, and you'll waste a day.
Verify with:
adb shell pm get-app-links com.yourco.yourapp
Look for verified next to each domain. legacy_failure means Android tried and gave up; none means it never tried, usually because autoVerify isn't set or the intent filter is malformed.
Expo Router: where the routing layer bites
Expo Router (file-based routing built on React Navigation) handles the mapping from URL to screen for you — most of the time. The config lives in app.json:
{
"expo": {
"scheme": "yourapp",
"ios": {
"associatedDomains": ["applinks:shop.example.com"]
},
"android": {
"intentFilters": [{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "shop.example.com" }],
"category": ["BROWSABLE", "DEFAULT"]
}]
}
}
}
That looks tidy. Here's what actually goes wrong.
The initial URL race condition
If a user cold-taps a link, iOS launches your app and hands the URL to the JS runtime before your root layout mounts. Expo Router handles the initial URL via Linking.getInitialURL(), but if your root layout waits on auth state, splash gating, or a feature flag fetch, the URL can land before the target screen exists in the stack — and the user ends up on your home screen, confused.
The fix is to queue the pending route in a top-level provider and replay it after your gating logic finishes:
// app/_layout.tsx
const pendingUrl = useRef<string | null>(null);
useEffect(() => {
Linking.getInitialURL().then((url) => {
if (url) pendingUrl.current = url;
});
const sub = Linking.addEventListener('url', ({ url }) => {
if (!authReady) pendingUrl.current = url;
else router.push(toInternalPath(url));
});
return () => sub.remove();
}, [authReady]);
useEffect(() => {
if (authReady && pendingUrl.current) {
router.push(toInternalPath(pendingUrl.current));
pendingUrl.current = null;
}
}, [authReady]);
Dynamic segments and the web fallback
If your Expo Router routes use [id].tsx and your marketing site uses the same URL shape, great. If they don't — common when marketing owns /products/widget-pro-2024 as a slug and the app expects /product/:id — you need a translation layer. Either align the URLs (cleaner, requires coordination) or add a redirect on the web side that rewrites to the app-friendly shape before the AASA matcher sees it.
AASA's components matching runs on the raw path, so if you redirect server-side the app will never see the URL unless the first hop matches.
Testing without losing your mind
The worst thing about deep links is that half the bugs only reproduce on a real device, installed from the actual distribution channel. A few habits that save time:
- Keep a
scripts/test-link.shthat runsxcrun simctl openurl bootedandadb shell am start -W -a android.intent.action.VIEW -dfor both scheme and HTTPS variants. Run it on every PR that touches routing. - Set up a staging domain (
staging-shop.example.com) with its own AASA and assetlinks pointing at your dev/internal bundle IDs. Never test production links against a dev build. - Add a hidden debug screen in your app that calls
Linking.getInitialURL()andLinking.addEventListener('url', ...)and renders every URL it sees. Ship it behind a long-press on the version string in settings. QA will thank you. - For App Store review: Apple reviewers test links from Notes. If your Universal Links don't work from Notes (they should, if AASA is correct), expect a rejection with a cryptic screenshot.
Where we'd start
If you're adding deep links to an existing React Native app in 2026, do it in this order: get custom scheme working first (fastest feedback loop), then Universal Links on iOS, then App Links on Android, then the Expo Router integration, then deferred deep linking if the product genuinely needs it. Don't try to debug all four layers at once — you won't know which one is lying to you.
And before you ship, delete the app, tap a link from a real text message on a real device, and make sure it opens the right screen after install. If you want help auditing a messy linking setup or planning a cross-platform launch, that's the kind of thing our mobile team does on a regular basis.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Hermes vs JSC in 2026: When We Still Reach for JavaScriptCore on React Native
Hermes is the default React Native engine in 2026, and for most apps that's the right call. But we still ship a few projects on JavaScriptCore — here's exactly when and why.
Background Tasks in React Native 2026: What iOS and Android Will Actually Run
Background execution is where cross-platform promises go to die. Here's what Expo, iOS, and Android actually let you run in 2026 — and the patterns that survive both OS schedulers and app review.

App Store Review Rejections in 2026: The React Native Patterns That Keep Getting Flagged
A field report on the App Store and Play Store rejection patterns we see hit React Native and Expo apps most often in 2026, with concrete fixes before you resubmit.
