For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Deep linking
Open auth, invites, and dashboard screens from custom schemes, Universal Links, App Links, and push notification taps.
Deep links take users from email, push notifications, or the web into a specific screen in your Expo app. TurboStarter already ships a custom scheme, Expo Router routes, and auth callbacks that build app URLs with expo-linking. You add Universal Links (iOS) and App Links (Android) when you want https:// URLs on your domain to open the app instead of the browser.
Development build for production links
Custom schemes work in development builds. Universal Links and App Links require a development build (or store build) - Expo Go cannot embed your associated domains. See development builds.
Architecture
Out of the box you get:
- Custom scheme
turbostarterinapps/mobile/app.config.ts - Expo Router with deep linking enabled for every file-based route
- Canonical paths in
apps/mobile/src/config/paths.ts(pathsConfig) - Auth and invite flows that pass
Linking.createURL(...)/x-urlso emails open in the app - Better Auth
trustedOriginsthat allow theturbostarter://scheme
Still on you for production HTTPS links: associatedDomains, Android intentFilters, and .well-known verification files on the web domain.
Linking flow
Incoming URLs map to Expo Router paths. Paths mirror pathsConfig:
| URL path | App route |
|---|---|
/auth/join | pathsConfig.setup.auth.join |
/auth/password/update | pathsConfig.setup.auth.updatePassword |
/dashboard/* | matching pathsConfig.dashboard entries |
/dashboard/organization/* | org dashboard routes |
Two URL shapes:
- Custom scheme -
turbostarter://auth/join?invitationId=…(already configured) - HTTPS -
https://yourdomain.com/auth/join?invitationId=…(Universal / App Links after you finish the steps below)
Auth emails prefer the scheme URL today via Linking.createURL and the x-url header. Universal Links are for marketing, shared web URLs, and any flow where you want the same https link to open the app when installed (and the website when not).
Custom scheme
The scheme is already set:
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
scheme: "turbostarter",
// ...
});If you rename it:
- Update
schemeinapp.config.ts - Update Better Auth
trustedOriginsinpackages/auth/src/server.ts - Rebuild the native app (
eas buildor local prebuild) - scheme changes are not applied by OTA alone
Trusted origin (required so auth redirects are not treated as open redirects):
trustedOrigins: [
"chrome-extension://",
"turbostarter://",
"https://appleid.apple.com",
// ...
],See Auth & deep links for the security checklist.
Existing deep links
Mobile clients pass an app URL so the API can build email links that open the native app:
redirectTo: Linking.createURL(pathsConfig.setup.auth.updatePassword),authClient.organization.inviteMember(params, {
headers: {
"x-url": Linking.createURL(pathsConfig.setup.auth.join),
},
});On the server, getUrl() in @workspace/auth prefers the x-url (or expo-origin) header so reset, invite, and verification emails resolve to turbostarter://… instead of a web-only URL.
The auth client also sends the scheme origin on every request:
fetchOptions: {
headers: {
"x-client-platform": Platform.MOBILE,
origin: Linking.createURL(""),
},
},Keep pathsConfig and these createURL / x-url call sites in sync when you add screens that should be reachable from email.
Universal Links (iOS)
Two-way association: the website proves it owns the app, and the app declares the domain.
1. Associated domains
Add your production (and staging) host without https://:
ios: {
bundleIdentifier: "com.turbostarter.core",
associatedDomains: ["applinks:yourdomain.com"],
// ...
},Rebuild with EAS so the Associated Domains entitlement is registered. The host should match EXPO_PUBLIC_SITE_URL / appConfig.url.
2. Apple App Site Association
Serve an apple-app-site-association file (no file extension) from your web app:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.turbostarter.core",
"paths": ["/auth/*", "/dashboard/*"]
}
]
}
}Replace TEAMID with your Apple Team ID and keep the bundle ID in sync with ios.bundleIdentifier. Scope paths to routes you actually want in-app (v1: auth + dashboard is enough).
The file must be reachable over HTTPS at:
https://yourdomain.com/.well-known/apple-app-site-association
Validate with an AASA validator after deploy. iOS caches the AASA aggressively - path changes for store builds often need a new App Store version before every device refreshes.
iOS Universal Links
docs.expo.dev
App Links (Android)
1. Intent filters
android: {
package: "com.turbostarter.core",
intentFilters: [
{
action: "VIEW",
autoVerify: true,
data: [
{
scheme: "https",
host: "yourdomain.com",
pathPrefix: "/",
},
],
category: ["BROWSABLE", "DEFAULT"],
},
],
// ...
},autoVerify: true is required for verified App Links. Narrow pathPrefix (for example /auth or /dashboard) if you do not want every path claimed by the app.
2. Digital Asset Links
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.turbostarter.core",
"sha256_cert_fingerprints": ["XX:XX:…"]
}
}
]Get the SHA-256 fingerprint from EAS:
eas credentials -p android
# Select the build profile → copy SHA256 FingerprintOr from Google Play Console → Release → Setup → App Signing. Include both upload and app-signing fingerprints when Play App Signing is enabled.
Serve the file as application/json over HTTPS at:
https://yourdomain.com/.well-known/assetlinks.json
Android App Links
docs.expo.dev
Push notification taps
Push payloads can carry a route in data. Wire navigation in NotificationsProvider (or a dedicated hook) when the user taps:
Notifications.addNotificationResponseReceivedListener((response) => {
const url = response.notification.request.content.data?.url;
// router.push(url) — validate against pathsConfig / an allowlist
});Only put non-sensitive identifiers in data. Validate the path before navigating - treat notification URLs like any other untrusted entry point (Auth & deep links).
Example payload when sending from your backend:
{
"to": "ExponentPushToken[…]",
"title": "Invitation",
"body": "Join the organization",
"data": { "url": "/auth/join?invitationId=…" }
}See Push notifications for delivery setup.
Test deep links
Custom scheme
With a development or store build installed:
# iOS
npx uri-scheme open "turbostarter://dashboard" --ios
# Android
npx uri-scheme open "turbostarter://dashboard" --androidAuth paths to smoke-test:
npx uri-scheme open "turbostarter://auth/join?invitationId=test" --ios
npx uri-scheme open "turbostarter://auth/password/update" --iosUniversal / App Links
On a physical device with a build that includes associated domains / intent filters:
- Deploy
.well-knownfiles to the production (or staging) domain - Install the matching native build
- Open
https://yourdomain.com/auth/joinfrom Notes / Messages (not only the browser address bar) - Confirm cold start (app killed) and warm start (app backgrounded)
Android intent smoke test:
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://yourdomain.com/dashboard"In-app browsers (some social apps) may open HTTPS links inside a webview and skip Universal / App Links - document that limitation for marketing links.
Multiple environments
Each domain needs its own AASA / assetlinks.json. If you use staging.yourdomain.com:
- Add
applinks:staging.yourdomain.comtoassociatedDomains - Add a matching Android intent filter
host - Host
.well-knownon the staging host as well
Prefer separate application IDs / EAS projects for production vs preview so signing fingerprints stay isolated. See Multiple environments.
Troubleshooting
| Symptom | What to check |
|---|---|
| Scheme does nothing | Rebuild after changing scheme; confirm with npx uri-scheme on a device/simulator |
| Auth email opens browser only | Mobile request sent x-url / Linking.createURL; trustedOrigins includes your scheme |
| HTTPS opens site, not app (iOS) | AASA valid and public; associatedDomains uses applinks:host without https; Team ID + bundle ID match; rebuild after entitlement change |
| HTTPS opens chooser / browser (Android) | autoVerify: true; assetlinks.json fingerprint matches the installed build; wait for verification (can take a minute+) |
| Works in debug, fails in Play/App Store | Production signing fingerprint / App Store AASA cache; issue a new store build after path changes |
| Notification tap does nothing | data.url present; response listener navigates; path allowlisted |
How is this guide?
Last updated on