React Native deep linking — the complete setup guide
Deep linking is one of those features that looks simple in the docs and takes a full day the first time you set it up. Between iOS universal links, Android App Links, URI schemes, Expo Router configuration, and handling the case where the app is not open yet, there are a dozen places to go wrong. Here is the complete setup with everything that actually matters.
Two types of links
You need to support both:
- Universal Links (iOS) / App Links (Android):
https://myapp.com/item/123— opens the app if installed, falls back to the website. Works from email, SMS, Notes, anywhere HTTPS links appear. - URI scheme:
myapp://item/123— always opens the app, no web fallback. Required for OAuth redirects and some third-party integrations.
Step 1: Configure the URL scheme (Expo)
{
"expo": {
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.example.myapp",
"associatedDomains": ["applinks:myapp.com"]
},
"android": {
"package": "com.example.myapp",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "myapp.com",
"pathPrefix": "/"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
Step 2: HTTPS domain verification files
Both iOS and Android require a verification file on your domain to prove you own it.
iOS — serve at https://myapp.com/.well-known/apple-app-site-association (no .json extension):
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.example.myapp"],
"components": [
{ "/": "/item/*" },
{ "/": "/profile/*" },
{ "/": "/invite/*" }
]
}
]
}
}
Android — serve at https://myapp.com/.well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": ["YOUR_SHA256_FINGERPRINT"]
}
}]
Both files must be served with Content-Type: application/json and accessible without redirects. Test with Apple's validation tool and Google's Statement List Generator.
Step 3: Expo Router link configuration
// app.config.ts
export default {
expo: {
scheme: 'myapp',
experiments: {
typedRoutes: true,
},
},
};
With Expo Router, file paths map directly to URLs. app/item/[id].tsx handles both myapp://item/123 and https://myapp.com/item/123 automatically. No additional routing configuration needed.
Step 4: Handling incoming links
// app/item/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { useEffect } from 'react';
export default function ItemScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
useEffect(() => {
// Track deep link open in analytics
analytics.track('deep_link_opened', { screen: 'item', id });
}, [id]);
return <ItemDetail id={id} />;
}
Step 5: The tricky case — auth required to view the link destination
A user taps myapp://invite/abc123 before logging in. Your auth check redirects to login. After login, you need to redirect them to the original destination. Here is how:
// contexts/AuthContext.tsx
import { useStorageState } from '../hooks/useStorageState';
import { router, useSegments, usePathname } from 'expo-router';
import { useEffect, useRef } from 'react';
export function AuthProvider({ children }) {
const [user, setUser] = useAuthState();
const segments = useSegments();
const pathname = usePathname();
const pendingLinkRef = useRef<string | null>(null);
useEffect(() => {
const inAuthGroup = segments[0] === '(auth)';
if (!user && !inAuthGroup) {
// Save the current path so we can redirect after login
pendingLinkRef.current = pathname;
router.replace('/login');
}
if (user && inAuthGroup) {
// After login, check for pending link
const destination = pendingLinkRef.current ?? '/';
pendingLinkRef.current = null;
router.replace(destination);
}
}, [user, segments]);
return <AuthContext.Provider value={{ user, setUser }}>{children}</AuthContext.Provider>;
}
Step 6: OAuth redirect URI
When using OAuth (Google, GitHub, etc.), you need to register myapp://oauth/callback as a redirect URI. Handle it:
// app/oauth/callback.tsx
import { useLocalSearchParams, router } from 'expo-router';
import { useEffect } from 'react';
export default function OAuthCallback() {
const params = useLocalSearchParams();
useEffect(() => {
const { code, state, error } = params;
if (error) {
router.replace('/login?error=oauth_failed');
return;
}
// Exchange code for token
exchangeCodeForToken(code as string, state as string)
.then(() => router.replace('/'))
.catch(() => router.replace('/login?error=token_exchange_failed'));
}, []);
return <LoadingScreen />;
}
Testing deep links
# iOS Simulator
xcrun simctl openurl booted "myapp://item/123"
xcrun simctl openurl booted "https://myapp.com/item/123"
# Android Emulator
adb shell am start -W -a android.intent.action.VIEW -d "myapp://item/123" com.example.myapp
# Expo Go (development)
npx uri-scheme open myapp://item/123 --ios
The most common mistake: forgetting to rebuild the native app after changing app.json. Configuration changes require a new build — expo prebuild or EAS Build. Shaking the device and hot-reloading does not pick up app.json changes.