Deep linking is one of those features that looks finished long before it is. A developer tests a link with the app already open, the correct screen appears, and the ticket closes. In real use most links are opened by people whose app is not running — from a notification, an email, a message — and that is the path nobody tested.
The symptom is familiar from the user side. You tap a link about a specific thing, the app launches, and you land on the home screen with no indication of what you were trying to reach. The link technically worked and the intent was lost, which for the person tapping is the same as it not working.
This post covers the three states a link can arrive in, how to set up verified links rather than custom schemes, and the specific things that break — including the ones that only break in production.
Why is cold start the hard case?#
Because the app has to finish becoming an app before it can navigate anywhere.
When the app is already running, a link arrives as an event and the navigation stack exists to receive it. When the app is not running, the link arrives as part of launch — before the JavaScript has loaded, before the navigator has mounted, and often before you know whether the user is even logged in. Navigating at that moment does nothing, silently.
| State | How it arrives | What usually breaks |
|---|---|---|
| Foreground | An event | Rarely anything |
| Background | An event on resume | Occasionally the stack is stale |
| Not running | Part of launch | Navigator not mounted yet |
The bottom row is the majority of real usage and the one that gets tested last. Anything you build should be tested by force-quitting the app first, because that is how most links are actually opened.
Hold the intent, do not fire it#
The pattern that works is capturing the incoming URL, storing it, and acting on it once the app declares itself ready — navigator mounted, session resolved, initial data loaded. Attempting to navigate immediately and hoping the stack exists is the underlying cause of most cold-start failures.
Authentication is part of readiness#
A link to a screen requiring a session cannot resolve before you know whether there is one. Holding the intent through the auth check and applying it afterwards — including after a login the link itself triggered — is what makes shared links work for logged-out users.
What kind of links should you use?#
Verified https links — Universal Links on iOS, App Links on Android — not a custom scheme.
A custom scheme like myapp:// is easy to set up and has two serious problems. It does nothing if the app is not installed, so a link in an email is a dead end for anybody who has not installed yet. And any other app can claim the same scheme, which is a genuine hijacking risk for anything carrying a token.
| Custom scheme | Verified https link | |
|---|---|---|
| Setup | Trivial | Requires a file on your domain |
| App not installed | Fails | Opens the web page |
| Can be claimed by others | Yes | No — domain-verified |
| Works from email and messages | Unreliably | Yes |
| Good for | Internal use, OAuth callbacks | Everything user-facing |
The second row is the one that matters commercially. A verified link opens the app for people who have it and the website for people who do not, which means one URL works for your whole audience instead of only the installed portion.
The association files are the whole setup#
iOS looks for apple-app-site-association and Android for assetlinks.json, both served from a well-known path on your domain over HTTPS with no redirects. Getting these right is most of the work, and getting them slightly wrong fails silently.
// https://example.com/.well-known/assetlinks.json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AB:CD:..."]
}
}] Use the production signing fingerprint#
The certificate fingerprint in the Android file must match the key the released app is signed with. If the store re-signs your app — which is normal with app signing enabled — the fingerprint is the store's, not your upload key, and using the wrong one is why links work in a local build and not in production.
Cache behaviour makes this slow to debug#
Both platforms fetch and cache the association file at install time, so a corrected file may not take effect until reinstall. That delay makes this feel intermittent and is the main reason it consumes an afternoon rather than an hour.
How should the routes be defined?#
As a declarative map from URL patterns to screens, matching your website's structure.
The link configuration should mirror the real web URLs, so /products/123 opens the product screen with that id and also works as a web page. Inventing app-only paths means maintaining two mental models of the same content and guarantees they drift.
const linking = {
prefixes: ['https://example.com', 'example://'],
config: {
screens: {
Product: 'products/:id',
Order: 'orders/:id',
Profile: 'u/:username',
NotFound: '*',
},
},
// Cold start: the URL that launched the app.
async getInitialURL() {
const fromNotification = await getNotificationLaunchUrl();
return fromNotification ?? (await Linking.getInitialURL());
},
}; Always define a catch-all#
A URL that matches no route should land somewhere sensible with an explanation, not nowhere. Links get shared long after content is removed, and a wildcard route handling that gracefully is a few lines against a dead end.
Give every deep-linked screen a back destination#
Arriving directly at a product page from a cold start leaves no history, so the back gesture exits the app. Constructing a sensible stack — the list beneath the detail — means the user can continue rather than being ejected.
Validate the parameters#
A URL is user input and can contain anything. An id that is not a number, a username with unexpected characters, a truncated link — each needs to fail into the not-found route rather than into a crash, and parsing with a schema is the same discipline as any boundary.
Keep the mapping in one file#
Route patterns tend to accumulate in several places once notifications, share sheets and marketing links all need to produce URLs. Keeping one module that both parses incoming links and constructs outgoing ones means a path can only be defined once, and a change to a URL structure cannot leave half the app pointing at the old shape.
How do notifications fit in?#
A notification tap is a deep link, and it should go through exactly the same path.
The temptation is to handle notification taps separately, because the payload arrives through a different API. That produces two navigation implementations that drift, and typically only one of them handles cold start correctly. Putting a URL in the notification payload and routing it through the same handler keeps one code path.
Cold start from a notification is its own case#
When the app was terminated, the tapped notification arrives as launch data rather than as an event, which is a different API from the one that fires when the app is running. Both need handling, and the launch case is the one that ships broken.
Version-guard the target#
A notification linking to a screen that only exists in a newer build reaches devices running older ones. Falling back to a sensible destination rather than crashing is what stops a campaign becoming a crash spike — and storing the app version alongside the push token lets you avoid sending it at all.
Never put credentials in a link payload#
A notification or link carrying a session token can be read by anything with access to it in transit through the OS. Send an identifier and let the app fetch what it needs with its existing credentials — which belong in secure storage, not in a URL.
What about links to content that needs a login?#
Capture the intent, authenticate, then continue — never discard it.
Somebody taps a link to a shared document, the app opens, they are not logged in, and they get the login screen. After logging in they should land on the document. Landing on the home screen instead means the link failed for them even though every individual step worked.
Store the pending route through the auth flow#
The intent has to survive the login screen, an account creation flow, an email verification round trip and possibly an app restart. Persisting it rather than holding it in component state is what makes that reliable.
Do not lose the link if they sign up instead#
A recipient without an account will often register rather than log in, which is a longer flow with more places for a stored intent to be dropped. Testing that specific path — link, register, verify, arrive — is worth doing deliberately, because it is the journey new users take and the one most likely to lose them.
Handle the wrong-account case#
A link to content belonging to a different account than the one signed in should say so and offer to switch, rather than showing a permission error that reads as a bug. This happens more than expected on shared devices.
Decide what unauthenticated users see#
For shareable content, a public preview with a prompt to sign in converts far better than a bare login wall. That is a product decision the linking implementation has to support, so it is worth making before building rather than after.
What breaks in production but not in development?#
Four things, and they are the reason this feature is deceptively expensive.
The signing certificate mismatch#
Already mentioned and worth repeating, because it is the most common production-only failure. Local builds are signed with your debug key and store builds are not, so verification succeeds in development and fails on the release.
Association files behind redirects or auth#
The file must be served over HTTPS with no redirect and no authentication, at the exact path. A hosting setup that redirects to a canonical domain, or serves the file with the wrong content type, breaks verification silently.
Links wrapped by other services#
Email marketing tools and link shorteners rewrite URLs for tracking, and the rewritten domain is not one your app has claimed. The link then opens a browser instead of the app, which looks like your implementation failing when it is the wrapper. Configuring the tool to use a domain you control resolves it.
Platform-specific interception#
Some apps open links in their own in-app browser rather than handing them to the system, which bypasses verified linking entirely. There is limited control over this and it is worth knowing so it is not diagnosed as your bug.
How do you test it properly?#
On real devices, from a cold start, using the actual delivery channels.
Force-quit before every test#
Testing with the app in the background exercises the easy path. Swiping the app away first and then tapping the link is the case that matters, and it should be how the feature is demonstrated as done.
Test from the channels you will actually use#
A link tapped from Notes behaves differently from the same link in an email client, a messaging app or a social feed, because each handles URLs its own way. Testing the real channels catches wrapping and in-app-browser interception that a local test never will.
Use the command line for iteration#
Simulator and emulator tooling can open a URL directly, which is far quicker than sending yourself messages while iterating on route configuration.
# iOS simulator
xcrun simctl openurl booted "https://example.com/products/123"
# Android device or emulator
adb shell am start -W -a android.intent.action.VIEW \
-d "https://example.com/products/123" com.example.app Add the common links to your release checklist#
Deep links break quietly — a domain change, a hosting migration, a new signing configuration — and nothing fails at build time to tell you. Opening three representative links from a cold start before each release takes two minutes and catches a regression that would otherwise be reported by a user weeks later.
Test the uninstalled case#
The behaviour for somebody without the app is half the value of verified links. The URL should render a real web page that works, ideally with a prompt to open or install the app, and that page is what most recipients will see.
What about deferred deep linking?#
Preserving the destination through an install — genuinely useful and not natively supported.
Somebody without the app taps a link to a specific item, installs, and opens for the first time. Ideally they land on that item. Neither platform provides this directly, because the link and the install are not connected, so it requires a third-party attribution service or a fingerprinting approach with real limitations.
It is a marketing feature with a privacy cost#
The services that provide it work by matching a browser visit to an install, which is exactly the kind of cross-context tracking platforms have been restricting. Accuracy has fallen and the privacy declarations it requires are not trivial.
A simpler version often suffices#
A landing page that shows the content and offers an install, combined with a clipboard hint or a code the user enters once, gets much of the benefit without an attribution SDK. It is less seamless and considerably less to explain in a privacy declaration.
What does it cost?#
A day for the setup, and half of it goes on the association files.
Route configuration, cold-start handling, the pending-intent flow through authentication and notification integration is a day of work. The association files, certificate fingerprints and the caching behaviour that makes them slow to verify account for a disproportionate share of it, and that is normal rather than a sign anything is wrong.
The honest counterweight: deep linking only pays off if links are actually part of how people reach your app. For a product where usage starts from the home screen icon and notifications are rare, a full verified-link setup is a day spent on a path few people take, and a basic scheme for OAuth callbacks would do. Check where your traffic actually comes from before building the complete version — and if the answer is that nobody links to your app, that may be worth more attention than the linking itself.
A link that opens the right screen when the app is running and the home screen when it is not has failed in the case that describes most real usage.
Conclusion#
Three arrival states matter — foreground, background and not running — and the last is both the most common in real use and the one that ships broken. Capture the incoming URL, hold it, and act only once the navigator has mounted and the session has resolved, rather than navigating immediately and hoping the stack exists.
Use verified https links rather than custom schemes. A custom scheme fails entirely for anybody without the app installed and can be claimed by other applications, while a verified link opens the app for people who have it and the website for everybody else — one URL for your whole audience.
Most of the setup is the association files: correct well-known paths, HTTPS with no redirects, and the signing fingerprint that matches the released build rather than your debug key. That last one is the classic production-only failure, and platform caching of those files is why debugging them takes an afternoon.
Mirror your web URL structure in the route config, always define a catch-all, construct a sensible back stack so a cold-start arrival does not eject the user, and validate URL parameters as the untrusted input they are. Route notification taps through the same handler rather than building a second navigation path.
Persist the pending route through login, account creation and verification so a shared link survives authentication, and never put credentials in a link payload. Then test by force-quitting first, from the channels you actually use, on real devices — and check that linking is genuinely how people reach your app before building the full version. If you want this done so it works on the path most people take, that is the level of detail I plan for.