You tap a notification from your favourite shopping app. Instead of landing on the home screen and hunting for the item yourself, you're taken straight to the product page.
That's deep linking and when it works well, users barely even notice it. That's the point.
At its core, a deep link is a URL that points to a specific location inside a mobile app, rather than just opening the app in general. Think of it like a web URL, visiting https://iam-kaz.com/blog/react-native-stack-2026 takes you to a specific blog page on a website. A deep link does the same thing, but the destination is a screen inside a native app.
I've worked with deep linking extensively in a production e-commerce app, and it ended up touching more of the product than I initially expected. We used it to convert mobile web traffic into app sessions, a user browsing a product on the website would see a smart banner prompting them to open it in the app, landing directly on that same product screen. Push notifications deep linked into specific offers or order updates. Marketing emails had CTAs that bypassed the home screen entirely and dropped users straight into the right flow.
Each of those touch-points, when done right, meaningfully improved the experience. When they broke, users noticed.
Deep links show up more often than you'd think. They power the “Open in app” prompts you see on websites, links inside push notifications, email CTAs that drop you into a specific flow, and the share links users send each other within apps. Without them, every one of those interactions would dump the user on the apps home screen, and most users wouldn't bother navigating from there.
For React Native and Expo developers, deep linking is one of those features that feels optional until your app needs it, and then it suddenly becomes essential. Getting it right unlocks a much smoother experience for your users, and it's more achievable than it looks once you understand how the pieces fit together.
Not all deep links work the same way though. There are several different types, each with their own trade-offs and the type you choose has real implications for how your app behaves, especially when it isn't installed.
Let's start by looking at the main types of deep links you'll encounter.
Link Types — Custom Schemes vs Universal Links & App Links
Before writing a single line of code, it's worth understanding that "deep link" is actually an umbrella term. There are two fundamentally different types, and they behave very differently in the real world.
Custom URI Schemes
The original approach to deep linking. A custom URI scheme works like a protocol, similar to https:// or mailto:// but one you define yourself for your app.
myapp://product/42
myapp://orders/recent
myapp://profile/settings
When the OS sees a link with your scheme, it knows to open your app and pass the URL along. Simple enough. The problem is what happens when your app isn't installed, the OS has nowhere to send the link, so it just fails silently. No redirect, no fallback, nothing. The user is left staring at a broken experience.
There's also a security concern. Any app can register any custom scheme. There's no verification or ownership, which means another app could technically register myapp:// and intercept your links. On a shared device that's unlikely, but it's a real limitation of the approach.
Custom schemes still have their place, they're quick to set up and work fine for internal use cases like OAuth redirects. But for anything user-facing, they fall short.
Universal Links (iOS) & App Links (Android)
The modern approach solves both problems. Instead of a custom protocol, Universal Links and App Links use standard https:// URLs, the same domain you already own.
https://mystore.com/product/42
https://mystore.com/orders/recent
https://mystore.com/profile/settings
These look identical to regular web URLs, that's because because they are. The difference is that you establish a verified association between your domain and your app. When the OS sees one of these links, it checks that association and opens the app directly to the right screen. If the app isn't installed, the link just opens in the browser like any normal URL. Graceful fallback, built in.
The verification step is the key distinction. Apple and Google both require you to host a file on your domain that proves you own both the website and the app. No file, no association, no deep link. We'll cover exactly how to set that up in the next section.
| Custom URI Scheme | Universal Link / App Link | |
|---|---|---|
| URL format | myapp://product/42 | https://mystore.com/product/42 |
| App not installed | Silent failure | Falls back to browser |
| Verification | None | Domain + app verified by OS |
| Platform | iOS & Android | iOS (Universal) / Android (App Links) |
| Setup complexity | Low | Medium |
For any production app with real users, Universal Links and App Links are the right choice. The setup takes a bit more work, but it's a one-time investment that pays off every time a user taps a link.
Setting Up Universal Links & App Links
Universal Links (iOS) and App Links (Android) both work on the same core principle. A verified, two-way association between your domain and your app. You need to prove to the OS that you own both. That proof lives in two files you host on your domain, and in the configuration you add to your Expo app.
Here's the full picture of what needs to happen:
- Host a verification file on your domain
- Configure your Expo app to claim that domain
- Build and submit your app (the OS fetches the verification file at install time)
Let's walk through each platform.
iOS — Universal Links
1. Host the Apple App Site Association (AASA) file
The AASA file is a JSON file that tells Apple which apps are allowed to handle URLs on your domain. It must be hosted at:
https://yourdomain.com/.well-known/apple-app-site-association
Note — no .json extension. Apple expects the file at this exact path, served with a Content-Type of application/json.
Here's what a full AASA file looks like for a fictional store app:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "ABCDE12345.com.mystore.app",
"paths": [
"/product/*",
"/orders/*",
"/profile/*",
"NOT /admin/*"
]
}
]
}
}A few things worth noting here:
appIDis your Apple Team ID followed by your bundle identifier, separated by a dot. You can find your Team ID in the Apple Developer portal.
pathsdefines which URL patterns your app should handle. is a wildcard.NOTexplicitly excludes a path — useful for admin routes or anything you only want opening in the browser.
- The
appsarray is always empty — it's a legacy field Apple still requires.
2. Configure app.json
With the file hosted, tell Expo your app wants to handle that domain:
{
"expo": {
"ios": {
"bundleIdentifier": "com.mystore.app",
"associatedDomains": [
"applinks:mystore.com",
"applinks:www.mystore.com"
]
}
}
}The applinks: prefix is required, it's how Apple knows this is a Universal Links claim rather than another type of associated domain. Add both your apex domain and www variant to cover all bases.
What Expo generates under the hood
When you run an EAS build, Expo takes your associatedDomains config and writes it into your app's entitlements file — MyStore.entitlements:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:mystore.com</string>
<string>applinks:www.mystore.com</string>
</array>
</dict>
</plist>This entitlement is what triggers iOS to fetch and verify your AASA file at install time. Without it, iOS won't attempt to claim your domain at all and your Universal Links simply won't fire, with no error to indicate why.
Android — App Links
1. Host the Digital Asset Links file
Android's equivalent is the assetlinks.json file, hosted at:
https://yourdomain.com/.well-known/assetlinks.json
Here's a full example:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.mystore.app",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}
]package_nameis your Android application ID, typically the same as your iOS bundle identifier.
sha256_cert_fingerprintsis the SHA-256 fingerprint of the certificate used to sign your app.
2. Configure app.json
{
"expo": {
"android": {
"package": "com.mystore.app",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "mystore.com",
"pathPrefix": "/"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}autoVerify: true is the critical field here. This tells Android to automatically verify the association between your domain and your app at install time. Without it, App Links won't work.
What Expo generates under the hood
Expo takes that intentFilters config and writes it directly into your AndroidManifest.xml, inside the <activity> tag for your main activity:
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="mystore.com"
android:pathPrefix="/" />
</intent-filter>
</activity>Each field maps directly from your app.json:
android:autoVerify="true"— tells Android to verify the domain association at install time. This is the most important attribute. Without it, your links will open in the browser instead of the app
android.intent.action.VIEW— declares this activity can handle view-type intents, i.e. opening URLs
BROWSABLE— allows the intent to be triggered from a browser or web link
DEFAULT— allows the intent to fire without explicitly naming your app
android:pathPrefix— defines which URL paths your app handles. You can add multiple<data>tags to handle different path patterns
Understanding the manifest gives you a much clearer picture of what's actually being verified when Android checks your assetlinks.json it's matching the host and pathPrefix values here against the domain you've claimed ownership of.
How Verification Actually Works
It's worth understanding what happens under the hood, because it explains why things can silently fail.
When your app is installed, the OS fetches the verification file from your domain in the background. On iOS, Apple also caches the AASA file via their CDN — which means changes can take up to 48 hours to propagate. On Android, verification happens at install time and can be retried, but a failed verification means your links will open in the browser instead of the app, with no obvious error.
Both platforms require your domain to be served over HTTPS. No exceptions.
A quick checklist before you build:
- ✅ AASA /
assetlinks.jsonhosted at the correct/.well-known/path
- ✅ Files served with correct
Content-Type: application/json
- ✅ Domain served over HTTPS
- ✅
associatedDomains/intentFiltersadded toapp.json
- ✅ SHA-256 fingerprint matches your EAS keystore (Android)
- ✅ Apple Team ID correct in
appID(iOS)
How Deep Linking Works in Expo
With your Universal Links and App Links configured, the next piece is handling incoming links inside your app. Both Expo Router and React Navigation build on top of expo-linking under the hood but how much of that you interact with directly depends on which router you're using.
Expo Router
If you're using Expo Router, deep linking works out of the box. Expo Router is built on the premise that every route in your app is a URL, so deep linking is baked in by design, not bolted on.
Your file structure is your URL structure:
app/
├── index.tsx → mystore.com/
├── product/
│ └── [id].tsx → mystore.com/product/42
├── orders/
│ └── index.tsx → mystore.com/orders
└── profile/
└── settings.tsx → mystore.com/profile/settingsA link to https://mystore.com/product/42 automatically resolves to app/product/[id].tsx with id available as a route param, no additional configuration needed. You access it directly in your screen:
import { useLocalSearchParams } from 'expo-router';
export default function ProductScreen() {
const { id } = useLocalSearchParams();
return (
// Render product with id
);
}Customising How Links Are Handled
The automatic file-based routing works well for most cases, but real-world apps often need more control. What if the URL coming in doesn't map neatly to your file structure? What if you're integrating with a third-party provider that sends arbitrary URL formats, or you have stale URLs from an older version of your app that need gracefully handling?
Expo Router provides two mechanisms for this.
+native-intent.tsx — Rewriting Incoming Links
For intercepting and rewriting deep links before Expo Router processes them, create a +native-intent.tsx file at the root of your app/ directory and export a redirectSystemPath function:
// app/+native-intent.tsx
export function redirectSystemPath({ path, initial }: {
path: string;
initial: boolean;
}) {
try {
if (initial) {
const url = new URL(path, 'myapp://app.home');
// Rewrite third-party provider URLs to internal routes
if (url.hostname === 'provider.mystore.com') {
const productId = url.searchParams.get('ref');
return `/product/${productId}`;
}
// Handle stale URLs from older versions
if (url.pathname.startsWith('/items/')) {
return url.pathname.replace('/items/', '/product/');
}
return path;
}
return path;
} catch {
// Never crash here — redirect to a safe fallback instead
return '/unexpected-error';
}
}The function receives the incoming path and an initial boolean — true if the app was cold started from this link, false if it arrived while the app was already open. You return the rewritten path and Expo Router takes it from there.
A couple of things worth noting:
+native-intentis native only, it won't fire on web
- It runs outside your app's React context, so you don't have access to things like authentication state or current route. For logic that depends on app state, use the approach below instead
- Always wrap the function in a
try/catchand return a fallback path, a crash here will break all incoming deep links
_layout.tsx — Redirecting Based on App State
For runtime redirects that depend on app state, authentication guards being the most common . Handle them in your root _layout.tsx using usePathname():
// app/_layout.tsx
import { Slot, Redirect } from 'expo-router';
import { usePathname } from 'expo-router';
import { useAuth } from '@/hooks/useAuth';
export default function RootLayout() {
const pathname = usePathname();
const { isAuthenticated } = useAuth();
if (!isAuthenticated && pathname !== '/login') {
return <Redirect href="/login" />;
}
return <Slot />;
}This approach has full access to your app's context — user state, feature flags, current route — making it the right tool for anything beyond simple URL rewriting.
React Navigation
React Navigation takes a more manual approach. You define a linking config object that explicitly maps URL paths to screen names and pass it into NavigationContainer:
import { NavigationContainer } from '@react-navigation/native';
import * as Linking from 'expo-linking';
const linking = {
prefixes: [
Linking.createURL('/'),
'https://mystore.com',
'myapp://'
],
config: {
screens: {
Product: 'product/:id',
Orders: 'orders',
ProfileSettings: 'profile/settings',
},
},
};
export default function App() {
return (
<NavigationContainer linking={linking}>
{/* Your navigator */}
</NavigationContainer>
);
}Linking.createURL('/') generates the correct local URL for your custom scheme in both Expo Go and production — so you don't have to hardcode myapp:// everywhere.
React Navigation handles cold starts and foreground links automatically via NavigationContainer, but every screen you want to be deep linkable needs an explicit entry in config.screens. Miss one and that URL silently falls through to your default screen.
If you need to handle a link manually outside of navigation, for example to trigger a side effect when a specific URL is received, that's where expo-linking's getInitialURL, addEventListener and useURL APIs come in directly.
Expo Router vs React Navigation — At a Glance
| Expo Router | React Navigation | |
|---|---|---|
| Deep link setup | Automatic — file structure = URL structure | Manual linking config required |
| Route params | useLocalSearchParams() | route.params |
| New screen deep linkable? | Yes, automatically | Only if added to linking config |
| URL customisation | +native-intent.tsx or _layout.tsx | Full control in linking config |
| Runtime redirects | _layout.tsx with usePathname() | Custom logic in NavigationContainer |
| Third-party URL rewriting | +native-intent.tsx | Manual in getInitialURL / addEventListener |
| Best for | New projects, URL-first architecture | Existing apps, complex custom routing |
If you're starting a new Expo project, Expo Router is the path of least resistance for deep linking. If you're working in an existing React Navigation codebase, the manual config is well documented and gets the job done. Either way, the underlying behaviour is the same — a URL maps to a screen.
Real-World Deep Link Examples
The best way to understand deep link structures is to look at how apps you already use every day implement them. Here's a breakdown of real schemes and Universal Links from well-known apps.
Custom URI Schemes
These are the scheme:// style links we covered earlier. They work when the app is installed, and fail silently when it isn't.
Spotify
spotify://track/6rqhFgbbKwnb9MLmUQDhG6
spotify://album/4aawyAB9vmqN3uQ7FjRGTy
spotify://artist/06HL4z0CvFAxyc27GXpf02
spotify://playlist/37i9dQZF1DXcBWIGoYBM5M
spotify://user/kazeemonis
spotify://search/kendrick%20lamarYouTube
youtube://watch?v=dQw4w9WgXcQ
youtube://channel/UCxxxxxxxxxxxxxxxxxxxxxx
youtube://results?search_query=expo+deep+linking
youtube://shorts/xxxxxxxxxxxUniversal Links & App Links
These use your domain's https:// URLs. They open the app directly when installed, and fall back to the browser when not.
https://www.instagram.com/iam-kaz/
https://www.instagram.com/p/CxxxxxxxxxX/
https://www.instagram.com/reels/CxxxxxxxxxX/
https://www.instagram.com/explore/tags/reactnative/
https://www.instagram.com/stories/iam-kaz/ASOS
https://www.asos.com/men/
https://www.asos.com/asos-design/asos-design-slim-fit-shirt/prd/12345678
https://www.asos.com/women/sale/
https://www.asos.com/my-account/orders/
https://www.asos.com/search/?q=trainersSpotify — The Best of Both Worlds
Spotify is worth calling out specifically because it supports both approaches, making it the clearest real-world illustration of the contrast between URI schemes and Universal Links.
# URI Scheme — works only if Spotify is installed
spotify://track/6rqhFgbbKwnb9MLmUQDhG6
# Universal Link — opens Spotify if installed, falls back to web player if not
https://open.spotify.com/track/6rqhFgbbKwnb9MLmUQDhG6Both links point to the same content. But the Universal Link version is what Spotify uses in share links, marketing campaigns and social posts, because it works for everyone, regardless of whether they have the app. The URI scheme lives in the background, as a fallback the app itself might trigger internally.
Same destination, fundamentally different behaviour depending on whether the app is installed. That's exactly why Universal Links are the right default for anything user-facing.
Testing & Gotchas
Getting deep linking configured is one thing. Verifying it actually works is another. Here's how to test each layer, along with the issues most likely to trip you up along the way.
Testing Custom URI Schemes and Universal Links
The same xcrun and adb commands work for both custom schemes and Universal Links. Just swap in the URL you want to test.
iOS Simulator:
# Custom scheme
xcrun simctl openurl booted "myapp://product/42"
# Universal Link
xcrun simctl openurl booted "https://mystore.com/product/42"Android Emulator:
# Custom scheme
adb shell am start -W -a android.intent.action.VIEW -d "myapp://product/42" com.mystore.app
# App Link
adb shell am start -W -a android.intent.action.VIEW -d "https://mystore.com/product/42" com.mystore.appIf the correct screen opens, your link handling is working. If the browser opens instead of the app, domain verification likely hasn't completed. Either the AASA or assetlinks.json isn't being served correctly, or the app needs reinstalling after those files were fixed.
For a more realistic iOS test that better mimics how a user would actually encounter a Universal Link, paste the URL into the Notes or Reminders app on your simulator or physical device and tap it from there. This bypasses the Safari same-domain limitation and gives you a cleaner signal that the OS is correctly routing the link to your app.
Gotcha: If Safari is open on the same domain you're testing, iOS will open the link in Safari rather than the app. This is intentional Apple behaviour, not a bug. Close Safari first, or use Notes and Reminders as described above, and you'll get the expected result.
Gotcha: Expo Go has its own URL scheme (exp://), which means your custom scheme won't work inside Expo Go. You'll need a development build via EAS to test your own scheme properly. This catches a lot of developers out early.Verifying Your AASA and assetlinks.json Files
Before testing on a device, it's worth verifying your hosted files are correct and reachable.
iOS — Apple's Validation Tool
Apple provides an official AASA validator at:
https://app-site-association.cdn-apple.com/a/v1/yourdomain.com
Replace yourdomain.com with your domain and open it in a browser. Apple's CDN will fetch your AASA file and return it. A valid JSON response means Apple can reach your file. An error means your file either isn't hosted correctly or isn't being served with the right Content-Type.
Gotcha: Your AASA file must be served withContent-Type: application/json. Some static hosting setups strip the content type from extensionless files, which causes Apple's verification to silently fail. Run a quickcurlto check your response headers before assuming the file is correct:
curl -I https://yourdomain.com/.well-known/apple-app-site-associationGotcha: Apple serves your AASA file through their own CDN, which caches it aggressively. If you've recently changed your AASA file, iOS devices may not pick up the update for up to 48 hours. If you're testing changes, test on a freshly installed build where possible, or be patient with physical devices.
Android — curl
curl -I https://mystore.com/.well-known/assetlinks.jsonCheck that you get a 200 response and that the Content-Type is application/json. Then fetch the file itself and confirm your package_name and sha256_cert_fingerprints are correct:
curl https://mystore.com/.well-known/assetlinks.jsonGotcha: The SHA-256 fingerprint in yourassetlinks.jsonmust exactly match the certificate used to sign the installed build. If you're testing a debug build locally, the fingerprint will differ from your EAS production keystore. Always test App Links with a build signed by the same certificate that matches yourassetlinks.json.
Gotcha: Android verifies your domain at install time, not at link-tap time. If your assetlinks.json wasn't correctly served when the app was installed, no amount of tapping links will make App Links work. Fix the file first, then reinstall the app.Quick Reference Checklist
Before assuming your deep links are broken, run through this list.
iOS
- AASA hosted at
/.well-known/apple-app-site-associationwith no file extension
- Served over HTTPS with
Content-Type: application/json
-
appIDuses correct Apple Team ID and bundle identifier
-
associatedDomainsadded toapp.jsonwithapplinks:prefix
- App installed after AASA was correctly hosted
Android
-
assetlinks.jsonhosted at/.well-known/assetlinks.json
- Served over HTTPS with
Content-Type: application/json
-
sha256_cert_fingerprintsmatches the signing certificate of the installed build
-
autoVerify: trueset inintentFiltersinapp.json
- App reinstalled after
assetlinks.jsonwas correctly hosted
