Deep Linking
How a URL opens one specific screen in your app: custom schemes, universal links, App Links, the association files, and local testing.
Linking.createURL(path: string, options?: { queryParams?, scheme?, isTripleSlashed? }): stringA deep link is a URL that opens your app on a particular screen instead of wherever the user left off. Expo Router turns the URL path into a route, so myapp://profile/42 lands on app/profile/[id].tsx with no routing code of your own.
Two kinds of link
A custom scheme link uses a name you invent. You set scheme in the app config. Without it the app falls back to the iOS bundle identifier or the Android package name. Custom schemes need no server, but they do nothing when the app is not installed, and any other app may claim the same scheme.
A universal link on iOS, or an App Link on Android, is an ordinary https:// URL. It opens your app when installed and your website otherwise. Each platform checks that you own the domain by downloading a file over HTTPS: /.well-known/apple-app-site-association and /.well-known/assetlinks.json.
What trips people up
These are native settings, not JavaScript. Changing scheme, ios.associatedDomains or android.intentFilters requires a new build; a reload will not pick it up. The association files are fetched around install time, so an app already on a phone keeps the old path list until it updates.
Linking.createURL("/profile/42") is the shorthand for assembling the URL from your scheme by hand. Same result, except it picks the correct scheme in Expo Go, in a development build and in production, which a hand-written template literal cannot.
Example
import * as Linking from "expo-linking";
import { StyleSheet, Text, View } from "react-native";
export default function LinkDebugScreen() {
// The URL that launched or resumed the app, or null if it was opened normally.
const url = Linking.useLinkingURL();
// parse() splits a deep link into hostname, path and queryParams.
const parsed = url ? Linking.parse(url) : null;
const ref = parsed?.queryParams?.ref;
return (
<View style={styles.container}>
<Text style={styles.heading}>Incoming link</Text>
{/* Every string sits inside a Text. A bare string in a View throws. */}
<Text>{url ?? "opened from the home screen"}</Text>
<Text>path: {parsed?.path ?? "-"}</Text>
<Text>ref: {typeof ref === "string" ? ref : "-"}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, gap: 8, justifyContent: "center", padding: 24 },
heading: { fontSize: 18, fontWeight: "600" },
});When to use it
- Open a shared product page directly from a link in a chat message.
- Route a password-reset email link into the reset screen rather than the home screen.
- Send a push notification that opens the order it refers to.
- Hand a link to QA that jumps straight to the screen under test.
More examples
App config for a scheme and an App Link
The three settings a link needs, in app.json. The scheme covers myapp:// links on both platforms. associatedDomains covers iOS universal links and carries no protocol. intentFilters covers Android App Links, and autoVerify must be true or Android treats the URL as a plain web link and opens the browser. Reach for this at the start: changing any of it means rebuilding, so it is cheaper to set all three before your first development build than after.
{
"expo": {
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.example.myapp",
"associatedDomains": ["applinks:myapp.example.com"]
},
"android": {
"package": "com.example.myapp",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{
"scheme": "https",
"host": "myapp.example.com",
"pathPrefix": "/profile"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}The iOS association file
Served at https://myapp.example.com/.well-known/apple-app-site-association, with no file extension and under 128kb uncompressed. appID is your Apple team ID, a dot, then the bundle identifier. With Expo Router you can put the file in public/.well-known/ and let your web deployment serve it. Android's counterpart lives at /.well-known/assetlinks.json, must be sent as application/json, and is an array whose single object holds relation ["delegate_permission/common.handle_all_urls"], your package_name, and sha256_cert_fingerprints taken from eas credentials -p android or the Play Console's App Signing page.
{
"applinks": {
"apps": [],
"details": [
{
"appID": "ABCDE12345.com.example.myapp",
"paths": ["/profile/*", "/invite/*"]
}
]
}
}Testing a link without publishing
Use this to check routing before any domain work exists. uri-scheme hands the URL to the simulator exactly as another app would. Universal and App Links cannot be tested this way: typing the https URL into the browser address bar stays in the browser by design, so tap the link from Notes, Messages or a mail message instead. On Android, adb shell pm get-app-links com.example.myapp reports whether verification against assetlinks.json succeeded.
# Open a custom-scheme link against a development build on a simulator
npx uri-scheme open "myapp://profile/42?ref=email" --ios
npx uri-scheme open "myapp://profile/42?ref=email" --android
# Inside Expo Go the app path goes after /--/
npx uri-scheme open "exp://127.0.0.1:8081/--/profile/42" --ios
# Print the schemes declared in the project's native Info.plist and AndroidManifest.xml
npx uri-scheme list
Discussion