Back to Blog
DevelopmentOctober 27, 202611 min read

Deep Links That Work: The User Who Taps a Link Without the App

A link in an email opens the website instead of the app, or opens the app on the home screen and loses the context. How universal links and app links work, why they fail silently, and how not to lose the user who doesn't have the app installed yet.

IM
Ignacio MelendezFull-Stack & Game Developer
Deep Links That Work: The User Who Taps a Link Without the App

A user gets an email with a link to an order, taps it, the app opens and they see the order. The developer tries it on their phone and it works. QA tries it and gets the same result. Then it ships to production and the errors start: the link opens the website, or it opens the app but on the home screen instead of the order detail, or in the worst case, the user didn't have the app installed, so the link takes them to the store, they install it, open it and land on an onboarding that knows nothing about the link they tapped.

Deep links are one of those features where every piece is documented but the whole is not. There's a file that lives on the web server, a configuration in the native project, a verification the OS runs sometimes (and sometimes not) and a router in the app that has to be ready before the link's URL arrives. If any of those pieces fails, there's no visible error. The browser simply opens.

1. Three kinds of link

Before anything else, it's worth clarifying that there are three things that usually get mixed up under the name "deep link":

  • Custom schemes (miapp://pedido/123). This is the oldest mechanism. The app registers a "scheme" and the system hands it any URL that starts with it. It works as long as the app is installed. Otherwise, the browser shows an error or does nothing. The problem is that any other app can register the same scheme, so they would conflict.
  • Universal links (iOS) and App links (Android). Regular https URLs (like https://miapp.com/pedido/123). If the app is installed and the domain is verified, the system opens the app. If not, the website opens. This is the recommended mechanism today.
  • Deferred deep links. More a pattern than a system mechanism. The idea is to remember which link the user was going to so you can take them there when they install and open the app for the first time (or when they get past a required login screen). Neither iOS nor Android solves this, so implementing it is the developer's job.

Custom schemes are still useful for OAuth callbacks or for communication between your own apps, but not for links users are going to see. Links sent by email have to work on the user's computer, where miapp:// leads nowhere and therefore doesn't work properly.

An easy rule for choosing one option or the other: any link that leaves the system (emails, push notifications, SMS, social media, QR codes) must be a real https URL with a web page behind it. The app doesn't replace that page; it complements it.

2. The contract between the domain and the app

The idea behind universal links and app links is the same: the operating system doesn't trust an app that says "I handle miapp.com". It needs the domain to confirm it. To do that, the domain creates and publishes a file at a specific path that says which apps can open its links and which can't.

On iOS it's apple-app-site-association, and on Android it's assetlinks.json. Both live under /.well-known/:

1{
2  "applinks": {
3    "details": [
4      {
5        "appIDs": ["ABCDE12345.com.miapp.app"],
6        "components": [
7          { "/": "/pedido/*", "comment": "Order detail" },
8          { "/": "/invitacion/*" },
9          { "/": "/cuenta/*", "exclude": true }
10        ]
11      }
12    ]
13  }
14}

On the app side, each platform declares the domains it wants to handle. On iOS it's the Associated Domains entitlement with applinks:miapp.com. On Android it's an intent-filter with autoVerify:

XML
1<intent-filter android:autoVerify="true">
2    <action android:name="android.intent.action.VIEW" />
3    <category android:name="android.intent.category.DEFAULT" />
4    <category android:name="android.intent.category.BROWSABLE" />
5    <data android:scheme="https" android:host="miapp.com" android:pathPrefix="/pedido" />
6</intent-filter>

So far, this is all official documentation. The problem is that this contract has two signatures, the domain's and the app's, and the system only checks that they match at very specific moments. If they don't match, nobody is told: the developer never finds out and the user suffers it without really knowing what's going on.

3. Why the link opens the website

After quite some time working with deep-links, these are some of the most notable failures. None of them produces a visible error, and nearly all of them end up opening the website.

3.1 The certificate fingerprint isn't the one the developer thinks

On Android, assetlinks.json contains the SHA-256 of the certificate the app is signed with. When using Play App Signing (the usual choice here), Google re-signs the APK with its own key before distributing it. So the fingerprint from the local keystore is the upload key's, not the one of the app the user installs. The result is that development builds work, but Play Store ones don't.

3.2 The file doesn't arrive the way the system expects

It has to be served over HTTPS, with no redirects. Not even from miapp.com to www.miapp.com. It also has to be served with Content-Type: application/json and be reachable without authentication (I've seen several cases where it was blocked waiting for a username and password that were never going to arrive).

If you don't account for this, a CDN that adds a redirect, a WAF that blocks requests without cookies or a Next.js rule that rewrites /.well-known/ will break everything.

3.3 Verification ran once. It failed

Android verifies domains when the app is installed (although recent versions also do it periodically). iOS, on the other hand, downloads the file through Apple's CDN, not directly from the app's server, and then caches it. When you deploy, if the file was wrong, a fix doesn't solve the problem on devices that had already checked it.

Since Android 12, an unverified domain no longer shows the "open with" dialog; it opens the browser directly.

3.4 The link doesn't come from where you thought

iOS universal links don't trigger if the user types the URL directly into Safari or navigates from a page on the same domain. The in-app browsers of some apps (social media, email clients) open the link in their own webview without going through the system. On top of that, many emails wrap the link in a tracking URL on another domain, so the link redirects through the email provider, not to the app.

The click tracking of email platforms is the silent killer of universal links. The link the user taps is https://click.proveedor.com/abc123, which redirects to https://miapp.com/pedido/123. By the time the redirect arrives, the decision to open the browser has already been made. The fix is to disable tracking on those links or set up your own tracking domain that is also associated with the app.

3.5 As always, the manufacturers

Just like with permissions, some manufacturer skins have their own per-app "open supported links" settings, and sometimes they come disabled by default or get reset after an update.

If a link works on a Pixel but not on another phone with the same Android version, check the phone's settings before touching any code.

4. Diagnose before touching code

The worst thing about deep links is debugging them blind. Luckily, both Android and iOS have tools to find out directly what the system thinks of the app's domain.

On Android, adb shows the verification state of each domain and lets you force a new verification. The commands are:

Bash
1# Verification state of the app's domains
2adb shell pm get-app-links com.miapp.app
3
4# Force a new verification (Android 12+)
5adb shell pm verify-app-links --re-verify com.miapp.app
6
7# Launch a link as if the user tapped it
8adb shell am start -a android.intent.action.VIEW -d "https://miapp.com/pedido/123"

If the state isn't verified, the problem is in the contract (it could be the file, the fingerprint or the intent-filter). If it's verified and the browser still opens, the problem is in how the link arrives (redirects, intermediate apps, manufacturer settings).

iOS doesn't have such a direct equivalent. You can check which version of the file Apple's CDN has by requesting it directly from https://app-site-association.cdn-apple.com/a/v1/miapp.com. If what it returns doesn't match what's on the app's server, iOS is working with the old version (or, if you forget a few times, with an even older one). For development, adding ?mode=developer to the domain in the entitlement makes the device download it directly from your server, bypassing the CDN.

It's worth adding a test to the deployment pipeline that checks the files return a 200 with no redirects, with the correct Content-Type, and that the fingerprint matches production's. It's a file that won't change much.

5. The app has to be ready to receive the URL

In React Native, a URL can arrive through two paths, depending on the app's state:

  • Cold start. The app wasn't open. The URL is obtained with Linking.getInitialURL().
  • Warm start. The app was in the background (it's very rare for it to be in the foreground and receive a request like this). The URL arrives as an event through Linking.addEventListener('url', ...).

The typical failures here are three:

  1. Navigating before the navigator is mounted, so the call is lost.
  2. Processing the same URL twice (once from getInitialURL and once from the event). This can cause the app to navigate to the same screen twice.
  3. Navigating to a protected screen before knowing whether the user is signed in, which sends them to the login and discards the destination.

A robust way to solve it is to store the URL as a "pending destination" and consume it when appropriate (after the Login, once you've checked there's only one pending navigation...).

1import { Linking } from "react-native"
2
3type PendingLink = { url: string; receivedAt: number }
4
5let pending: PendingLink | null = null
6let lastHandledUrl: string | null = null
7const listeners = new Set<() => void>()
8
9function setPending(url: string | null) {
10  // Ignore the same URL if it arrives through both paths at once.
11  if (!url || url === lastHandledUrl) return
12  pending = { url, receivedAt: Date.now() }
13  listeners.forEach((notify) => notify())
14}
15
16export function startDeepLinkCapture() {
17  Linking.getInitialURL().then(setPending)
18  const subscription = Linking.addEventListener("url", ({ url }) => setPending(url))
19  return () => subscription.remove()
20}
21
22export function consumePendingLink(): string | null {
23  if (!pending) return null
24  const { url } = pending
25  lastHandledUrl = url
26  pending = null
27  return url
28}
29
30export function onPendingLink(listener: () => void) {
31  listeners.add(listener)
32  return () => listeners.delete(listener)
33}

Navigation then consumes the destination only when the conditions are met. In the session case, once it has checked that the Login is done and the user can get in.

TypeScript
1function useDeepLinkNavigation(navigationReady: boolean, auth: AuthState) {
2  useEffect(() => {
3    const tryConsume = () => {
4      if (!navigationReady || auth.status === "loading") return
5      if (auth.status === "signedOut") return // stays pending until login
6
7      const url = consumePendingLink()
8      if (!url) return
9
10      const route = resolveRoute(url)
11      navigationRef.navigate(route.name, route.params)
12    }
13
14    tryConsume()
15    return onPendingLink(tryConsume)
16  }, [navigationReady, auth.status])
17}

With this model, the login case stops being a problem. The user taps the link, the app sees there's no session, shows the login, and as soon as auth.status changes to signedIn, the effect runs again and consumes the destination that was still pending.

A deep link isn't a navigation command. It's a user intent (the concepts often go hand in hand with Intents) that has to be kept until the app is able to fulfill it.

6. What happens when the user doesn't have the app

That leaves the hardest case: the user taps the link without having the app installed.

The system opens the website, the website suggests installing the app, the user goes to the store, installs it and opens it. But when the app opens, it no longer knows anything about the link, so it doesn't do anything with it. This is what's known as deferred deep linking. Neither platform offers it out of the box.

For years the answer was Firebase Dynamic Links, but Google shut it down in August 2025. The alternatives are third-party services (Branch, AppsFlyer, Adjust) or building it yourself, and here the two platforms are very different:

  • Android has an official, reliable solution: Google Play's Install Referrer API. If the store link carries a referrer parameter, the app can read it on its first launch. All the website has to do is add the original destination to that parameter when redirecting to the Play Store.
  • iOS has no equivalent. The App Store doesn't pass any parameter to the installed app. Some options are using the clipboard, but the user is shown a prompt to paste the content, or probabilistic fingerprinting (IP, model, language...), which is unreliable and runs head-on into Apple's privacy policies (and can get your app rejected).

In practice, on iOS it usually works better to redesign the flow so it doesn't depend on it. If the link is an invitation, the website can ask for the email before sending the user to the store, and the app recovers the invitation through the account after sign-up. If it's an order, the user will see it when they sign in anyway. That way, the app can survive without these URL hints.

The deferred deep link goes from being a requirement to being an improvement that only works well on Android.

TypeScript
1// First launch on Android: recover the destination from the Install Referrer.
2import { getInstallReferrer } from "./installReferrer" // native module wrapper
3
4export async function recoverDeferredLink() {
5  if (await storage.get("deferredLinkChecked")) return
6  await storage.set("deferredLinkChecked", true)
7
8  const referrer = await getInstallReferrer() // "utm_source=web&target=%2Fpedido%2F123"
9  const target = new URLSearchParams(referrer ?? "").get("target")
10  if (target) setPending(`https://miapp.com${target}`)
11}

A destination recovered from a referrer (or from any other external source) must be validated.

7. What to set up from the start

If I were starting an app with external links today, there are four things I'd have solved before the first user screenshot arrives:

  • A single domain for all links, with a real web page behind every route. No miapp:// in user communications, and no third-party tracking domains in front.
  • The /.well-known/ files verified in the pipeline, with the production fingerprint from Play Console and not the local keystore's.
  • A "pending destination" instead of direct navigation, consumed by a single piece that knows the state of the navigator and the session.
  • An allowlist of routes that maps URLs to screens, with parameter validation, so that no link can take the user somewhere they couldn't reach by navigating.

Related Articles

View all articles
Android Permissions Change Depending on the Phone Brand

Android Permissions Change Depending on the Phone Brand

Xiaomi, Samsung, Huawei... every manufacturer has its own permission hell. Why you end up writing your own permission manager, and how to tame the per-brand quirks.