Deep links that open the right screen: production checklist for Expo apps
Deep links that open the right screen: production checklist for Expo apps
Password-reset emails, referral invites, and order confirmations all end the same way: a link that should land on the right screen. In development the link works because the simulator is warm, the session is fresh, and you tap it from the same device. In production it breaks because the app was killed, the session expired, or the user pasted the link into a notes app first. Deep linking is not one feature. It is three layers that have to agree: the operating system deciding your app owns the URL, the router mapping that URL to a screen, and your app handling whatever state it wakes up in. When AI coding tools scaffold the router for you, the middle layer looks done while the other two are still missing. This post closes that gap for Expo apps built with Expo Router.
Common Failure Buckets
Most production deep-link bugs fall into four buckets. First, the OS never hands the URL to your app because the domain association file is missing or the native config is wrong, so the link opens in the browser instead. Second, the app opens but lands on the home screen because the route path does not match the URL structure. Third, the app lands correctly on a warm start but drops the destination on a cold start, when the JavaScript bundle loads after the OS delivers the intent. Fourth, the destination screen assumes an authenticated user, redirects to login, then forgets where it was going. Each bucket needs a different fix, which is why retesting the happy path never resolves production reports. You need the OS association verified, the route mapping explicit, and the cold-start plus unauthenticated paths handled as first-class cases.
OS Association Verification
The OS association mechanism requires two side-by-side artifacts to work: an intent filter in the native manifest and an assetlinks.json file on your domain. Expo config plugins generate the manifest entry from app.json, so declare the domain there rather than editing native files by hand.
{
"expo": {
"android": {
"package": "dev.otfkit.app",
"intentFilters": [
{
"action": "VIEW",
"data": [
{
"scheme": "https",
"host": "otf-kit.dev",
"pathPrefix": "/blog"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
}
]
}
}
}
Host the association file at https://otf-kit.dev/.well-known/assetlinks.json with the exact package name and the SHA-256 fingerprint of your production keystore. The common failure is testing with the debug fingerprint and shipping the release build, or vice versa. Record both fingerprints during setup and verify the hosted file returns JSON with a 200 status and no redirect.
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "dev.otfkit.app",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:C3:8B:65:76:36:E8:94:CE:AA:AC"
]
}
}
]
After installing a production-signed build, test from a real surface: an SMS message, an email, or a chat message, not solely through debug bridges. If Android still shows a chooser dialog, verification failed. Re-check the host, the fingerprint, and whether your CDN serves the JSON file with the wrong content type. Verification state survives updates, so a fix here stays fixed.
Route Mapping Configuration
On Android, App Links bind an https URL to your app through an intent-filter plus a hosted assetlinks.json file. On iOS, Universal Links bind an https URL through an associatedDomains entitlement plus a hosted apple-app-site-association file. When both sides match, the OS opens your app directly with no disambiguation dialog. When the app is not installed, the same URL falls back to your website, which is exactly the behavior a referral or receipt link should have.
The practical rule is simple: keep the custom scheme as a development convenience, ship universal links as the real path, and make both resolve to the same routes. That way QA can test with the scheme while users only ever see https links. Configure Expo Router once and trust the file routes Expo Router enables deep linking for every file route automatically, which removes a whole class of manual mapping bugs. Your job is to keep the URL structure stable and to avoid overriding the default behavior unless you have a concrete reason.
Start by defining the canonical URL shape in app.json before adding screens. One scheme for development, one web domain for production, with the native association declared in the platform sections covered below.
{
"expo": {
"expo": {
"scheme": "otfkit",
"extra": {
"webDomain": "https://otf-kit.dev"
}
}
}
}
Map each linkable destination to a file route and keep the segments identical to the web path. A receipt at https://otf-kit.dev/blog/expo-sqlite-offline-cache-apps resolves through the same segment structure as the screen file, not through a differently named screen plus a manual redirect. The fewer translations between URL and route, the fewer places a cold start can lose the parameter.
Cold Start and Unauthenticated Paths
Routing the URL is half the job. The other half is arriving gracefully when the app wakes from killed, the session is gone, or the parameters are stale. Treat the incoming URL as untrusted input. Validate the segments, fetch the resource, and render loading, not-found, and login-required states explicitly.
A receipt link with a deleted order should show a clear missing screen, not a spinner that never resolves. A referral link opened on a signed-out device should preserve the destination through sign-in and continue afterward instead of dropping the user at the home tab.
import { useEffect, useState } from 'react';
type LoadState = 'loading' | 'ready' | 'login' | 'missing';
export function useDeepLinkTarget(kind: string, id: string) {
const [state, setState] = useState('loading');
useEffect(() => {
let cancelled = false;
async function resolve() {
const session = await getSession();
if (cancelled) return;
if (!session) {
setPendingLink(`/${kind}/${id}`);
setState('login');
return;
}
const exists = await checkResource(kind, id);
if (cancelled) return;
setState(exists ? 'ready' : 'missing');
}
resolve();
return () => {
cancelled = true;
};
}, [kind, id]);
return state;
}
Persist the pending destination before redirecting to login, then consume it once after authentication completes. Keep the pending value in durable storage rather than in-memory state, because the OS may kill the app between the redirect and the login callback. Clear it after a single use so a stale invite does not hijack the next launch.
Cold starts deserve their own test because the timing differs. When the app is killed, the OS launches it and delivers the URL nearly simultaneously, which means session restoration, asset loading, and router mounting race the navigation event. Gate initial navigation on session restoration completing, with a splash screen that waits for a ready flag rather than a fixed timeout. Log the received URL, the resolved route, and the final screen on every cold start during QA so a dropped parameter is visible instead of silent. Offline behavior matters here too. A link opened with no connectivity should still land on the right screen with cached content or a clear offline state. The cache-first pattern from our Expo SQLite offline guide works well for receipt and article screens reached from links.
Testing Strategy
Deep links need a matrix, not a single tap test. Run each row on a production-signed build on physical devices, with the app in three states: foreground, background, and killed. Record the entry screen for every combination. Cover at minimum: password reset, invite, receipt, and marketing links; taps from SMS, email, chat apps, and pasted into the browser address bar; signed-in, signed-out, and expired-session states; app installed versus not installed; and Android plus iOS separately, because the association mechanisms fail independently. For each failure, note which layer broke: OS handoff, route mapping, or destination state. That classification tells you whether to fix native config, router structure, or screen logic.
Automate what you can. A small script that opens each canonical URL on simulators and emulators catches route-mapping regressions in continuous integration. Leave the OS association checks for real devices on release candidates, since emulators skip the verification steps that fail most often in production. Ship the domain files with the same care as code. Pin the association file URLs in your release checklist, monitor them with the same uptime check as your API, and treat any redirect, content-type change, or CDN caching incident as a linking outage.
Links are infrastructure once users rely on them.
Sources Expo linking overview: deep links, Android App Links, and iOS Universal Links
Comments
No comments yet. Start the discussion.