Quick path
Add a scheme to app.json, configure associatedDomains and intentFilters, host two JSON files at /.well-known/ on your domain, test on a real device. 1 hour on a clean Expo project.
Custom schemes vs Universal/App Links
| Aspect | Custom scheme | Universal / App Links |
|---|---|---|
| URL format | myapp://path | https://yourapp.com/path |
| Works uninstalled | No — does nothing | Yes — falls back to web |
| Browser behavior | Prompts user | Opens app silently if installed |
| Setup complexity | Low (app.json only) | Higher (domain verification) |
| Best for | Internal, dev, push payloads | Marketing, email, social |
Production apps should set up both: custom scheme for internal use, Universal/App Links for all external marketing.
Step 1: custom scheme in app.json
// app.json
{
"expo": {
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.yourco.myapp"
},
"android": {
"package": "com.yourco.myapp"
}
}
}After rebuild, myapp://profile/123 opens your app. Expo Router handles the routing from the URL to the matching file-based route automatically.
Step 2: iOS Universal Links
Configure associatedDomains in app.json:
// app.json
{
"expo": {
"ios": {
"associatedDomains": [
"applinks:yourapp.com"
]
}
}
}Host apple-app-site-association at https://yourapp.com/.well-known/apple-app-site-association (no extension, served as application/json):
{
"applinks": {
"apps": [],
"details": [{
"appID": "TEAMID.com.yourco.myapp",
"paths": ["*"]
}]
}
}Replace TEAMID with your Apple Team ID (found in the Apple Developer portal membership page).
Step 3: Android App Links
Configure intentFilters:
// app.json
{
"expo": {
"android": {
"intentFilters": [{
"action": "VIEW",
"autoVerify": true,
"data": [{
"scheme": "https",
"host": "yourapp.com"
}],
"category": ["BROWSABLE", "DEFAULT"]
}]
}
}
}Host assetlinks.json at https://yourapp.com/.well-known/assetlinks.json:
[{
"relation": ["delegate_permission/common.handle_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourco.myapp",
"sha256_cert_fingerprints": [
"XX:XX:XX:..."
]
}
}]The SHA-256 fingerprint comes from your EAS Build signing cert. Run eas credentials on Android to retrieve it.
Step 4: handle links in code
With Expo Router, this is usually done for you — a link like https://yourapp.com/profile/42 routes to app/profile/[id].tsx automatically. For edge cases, read the incoming URL manually:
import * as Linking from 'expo-linking';
import { useEffect } from 'react';
useEffect(() => {
Linking.getInitialURL().then((url) => {
if (url) handleInitialLink(url);
});
const sub = Linking.addEventListener('url',
({ url }) => handleIncomingLink(url));
return () => sub.remove();
}, []);Testing
- Custom scheme in dev:
npx uri-scheme open myapp://profile/42 --ios. - iOS Universal Links: send yourself an iMessage or email with the https link — tapping it should open the app. Terminal curl of the AASA file must return valid JSON.
- Android App Links verification:
adb shell pm verify-app-links --re-verify com.yourco.myapp. - Apple’s debugger: Settings → Developer → Universal Links on iOS to inspect link matching.
Common pitfalls
apple-app-site-associationserved with wrong MIME type or behind a redirect. Must be 200 + valid JSON.- Missing Team ID or wrong bundle ID in the AASA file. Apple won’t tell you — links just silently fall back to the browser.
- Android SHA-256 cert mismatch after switching between EAS build profiles. Keep prod cert fingerprints in
assetlinks.json. - Testing Universal Links in the simulator. They don’t work — use a real device.
- Not hosting a real web page at the same URL. When the app isn’t installed, users land on a 404.