Deep links that open the app every time
Two layers, different jobs
- Custom schemes (
myapp://...): trivial to set up, work from inside your own emails and QR flows, but any app can claim a scheme and browsers treat them as second-class. Fallback layer. - Universal links (iOS) / App links (Android) (
https://yourdomain.com/...): the real thing. They need server-side proof files and that is where every setup fails.
Ship both: schemes for internal redirects (OAuth return, payment return), https links for anything a user might tap outside the app.
iOS: the AASA file and Apple's CDN
The proof file lives at https://yourdomain.com/.well-known/apple-app-site-association: HTTPS, no redirects, JSON body, appID as TEAMID.bundleid. The entitlement side is applinks:yourdomain.com (Expo: associatedDomains in app config).
The part that costs people days: devices do not fetch your file directly. Apple's CDN fetches it (first pickup within about a day) and caches it, so an AASA fix is not live when your server says it is. For development there is an alternate mode: entitlement applinks:yourdomain.com?mode=developer plus the device's developer mode skips the CDN. Use it for iteration, remove it for release builds.
If links still open Safari instead of the app: long-press the link and check for the "Open in app" option, confirm the file is reachable with curl -i (status 200, no redirect chain), and remember that pasting a URL into Safari's address bar deliberately does NOT trigger universal links; tap one from Notes or Messages instead.
Android: assetlinks.json and the signing key trap
The proof file lives at https://yourdomain.com/.well-known/assetlinks.json with the app's package name and the SHA-256 fingerprint of the certificate that signs what ships. The trap: with Play App Signing (the norm), Google re-signs your app, so the fingerprint must come from Play Console's App integrity page (the app signing key), NOT from your upload key or local keystore. A local-keystore fingerprint makes verification fail silently and only in production, which is the worst combination.
Add fingerprints additively: dev builds and store builds can both live in the file. Intent filters need android:autoVerify="true" on the https filter. Check verification state on a device with:
adb shell pm get-app-links your.package.name
verified next to the domain is the goal; anything else names the failing domain.
Cold start vs warm start, the classic lost link
A link that launches the app arrives through the initial-URL path; a link tapped while the app runs arrives through the URL event. Handling only the event listener means cold-start links vanish, and that is the most common deep-link bug report. expo-router handles both when the scheme and domains are configured, but anything custom (auth guards, splash gates, onboarding redirects) must re-apply the pending link AFTER the gate resolves, or the user lands on the home screen and the link dies.
Test both paths explicitly: once with the app killed, once with it backgrounded.
Testing without a website deploy
# custom scheme, both platforms
npx uri-scheme open "myapp://lesson/42" --ios
npx uri-scheme open "myapp://lesson/42" --android
# https links on Android without waiting for verification
adb shell am start -a android.intent.action.VIEW -d "https://yourdomain.com/lesson/42" your.package.name
iOS https links cannot be faked from the terminal in the same way; that is what mode=developer exists for.
Adjacent traps
- OAuth and payment providers redirect back via your scheme or https link; a changed scheme breaks sign-in in the field, so treat the scheme as API surface and never rename it casually.
- Deferred deep links (install, then first open lands on content) are not a platform feature; they require an attribution/linking service. Do not promise the product team otherwise.
- Every route a link can open must handle "referenced thing is gone" gracefully; links outlive content.