Quick Answer

Quick Answer: A deep link is a URL that an app has registered itself to handle, so instead of opening a browser, the operating system hands the link straight to the app, which reads the link’s path and query parameters to decide which exact screen to show and with what data. The two “verified” flavours — Android App Links and iOS Universal Links — additionally require a small JSON file hosted on your own website, which is how the OS confirms you actually own both the domain and the app before skipping the browser entirely.

“Deep link” gets used loosely, but there are really three distinct mechanisms, and mixing them up causes confusion when debugging:

  • Custom URI scheme — something like priodemy://course/dsa-roadmap. The app registers the scheme (priodemy) with the operating system at install time, and any link using that scheme goes straight to the app, with no verification step at all. The downside: it only works if the app is already installed, and because there is no ownership check, in principle any app could register the same scheme (the OS generally resolves conflicts by whichever app the user picked or installed, which is exactly the ambiguity the next two mechanisms were built to avoid).
  • Android App Links — a normal https:// URL that Android has verified belongs to your app, by checking a file your website hosts. A verified App Link skips the usual “which app do you want to open this with” dialog and goes directly to your app; if the app is not installed, the same URL just opens in the browser instead, which is a meaningfully better fallback than a broken custom scheme link.
  • iOS Universal Links — the same idea on iOS: a normal https:// URL, verified through a file hosted on your domain, that opens directly in your app if it is installed and falls back to Safari if it is not.

In practice, a production app usually supports a custom scheme and App Links/Universal Links for the same destinations — the custom scheme for cases like a push notification payload or an internal redirect where verification does not matter, and the verified https form for anything shared outside the app, like a WhatsApp message or an email, where you want the link to work whether or not the recipient has the app installed yet.

How the Operating System Decides Where a Link Goes

For the verified flavours, both platforms use the same basic idea: a small, machine-readable file hosted at a fixed, well-known path on your domain, which the OS fetches and checks before it will route a link to your app instead of a browser.

On Android, this file is assetlinks.json, hosted at https://yourdomain.com/.well-known/assetlinks.json. It declares your app’s package name and the SHA-256 fingerprint of the certificate it is signed with (exactly the fingerprint covered in the companion article on Android app signing) — which is precisely why a release build signed with a different key than the one declared in this file will silently fail verification and fall back to the browser.

On iOS, the equivalent file is apple-app-site-association, hosted at https://yourdomain.com/.well-known/apple-app-site-association (served with no file extension; Apple’s own guidance also allows a .json fallback path, since iOS checks both). It lists your app’s Team ID and bundle identifier instead of a certificate fingerprint, but the goal is identical: prove the app and the domain are controlled by the same entity.

Both files must be served over plain HTTPS, with no redirects in the way — the OS fetches this file directly during app install (and periodically afterward) and caches the result, which is also why a change to this file does not take effect on already-installed devices instantly; it can take a noticeable delay to propagate.

There is a meaningful asymmetry between the two platforms worth knowing about: Android’s verification is checked by the operating system itself at specific moments (largely around install and update time), while there is no simple way for an app developer to force an immediate re-check on a device that already has the app installed when the verification file changes. iOS behaves similarly — the apple-app-site-association file is fetched and cached rather than checked on every single link tap. The practical consequence for both platforms is the same: do not expect a fix to your verification file to take effect instantly for users who already have the app installed; test with a fresh install when verifying a configuration change, not by relying on an existing device to notice the update.

Once the OS hands a URL to your app, the app still has to figure out which screen that URL means and extract any parameters. Here is a real router, written and run in Dart (dart run, Dart 3.11), that resolves three different deep link shapes to a screen name and typed params — the same core logic a go_router or uni_links-based setup performs internally:

ResolvedRoute? resolve(Uri uri) {
  final isWeb = uri.scheme == ‘http’ || uri.scheme == ‘https’;
  final segments = isWeb
      ? uri.pathSegments
      : [if (uri.host.isNotEmpty) uri.host, ...uri.pathSegments];

  if (segments.length >= 2 && segments[segments.length - 2] == ‘course’) {
    return ResolvedRoute(‘CourseDetailScreen’, {
      ‘courseId’: segments.last,
      ...uri.queryParameters,
    });
  }
  // ...certificate verify, reset-password handled similarly
  return null; // unknown shape -> caller falls back to home
}

Running this against priodemy://course/dsa-roadmap-beginners?utm_source=whatsapp and against https://priodemy.com/app/course/python-basics both correctly resolved to CourseDetailScreen with the right courseId — but getting there surfaced a genuine, easy-to-miss bug worth knowing about: Dart’s Uri parser treats the segment right after scheme:// in a custom-scheme URI as the host, not a path segment — the same way a browser treats example.com in https://example.com/x. So priodemy://course/abc has uri.host == ‘course’ and only [‘abc’] as path segments, not [‘course’, ‘abc’] as it looks at first glance. The fix — folding uri.host back into the segment list for non-web schemes, shown above — is exactly the kind of thing that passes a quick manual test with one link shape and then quietly breaks on a differently-shaped one in production.

Path segments (/course/python-basics) are the right place for identifying which resource a screen is about — they read clearly and are easy to generate consistently. Query parameters (?utm_source=whatsapp) are the right place for optional, non-identifying context: campaign attribution, referral source, an optional pre-filled search term.

The one rule that matters more than the path-vs-query convention: never treat data that arrives through a deep link as already authenticated or already validated, even if it looks like it came from your own notification system. A password reset link carrying a token in its query string, for example, still has to have that token verified server-side before anything sensitive happens — the link itself proves nothing about who tapped it, only that whoever tapped it had the link. The router’s job is just to route; verification is a separate, server-side step that has to happen regardless of how trustworthy the link’s origin looks.

A second practical habit: validate the shape of what you extract before using it. The demo router above explicitly rejects a reset-password link missing its token query parameter rather than passing an empty value forward — a screen that assumes a parameter exists and crashes on null when it does not is a routine, avoidable crash report.

It is also worth deciding, deliberately, how much of your app’s navigation structure to expose directly in link paths. A path like /course/dsa-roadmap-beginners is readable and stable — the slug is unlikely to change. A path that encodes an internal database row ID, or a deeply nested structure that mirrors your current screen hierarchy exactly, tends to break the first time you reorganize your navigation, because now your marketing links and anyone else’s saved bookmarks are tied to an implementation detail rather than a stable public identifier. Treating your deep link paths as a small, intentional public API — separate from however your app’s internal routing happens to be structured — saves real pain later.

Cold Start vs Warm Start: The Bug That Gets Everyone Once

There are two very different situations when a deep link arrives, and conflating them is probably the single most common deep-linking bug: warm start, where the app is already running and simply needs to navigate to a new screen, versus cold start, where tapping the link is what launches the app from nothing.

On a warm start, your navigation stack, router, and auth state are already initialized, so handling the link is just “navigate now.” On a cold start, the link arrives before your app has finished setting up — the router may not be mounted yet, and you may not even know yet whether the user is logged in. Naively trying to navigate immediately on a cold start either does nothing (because there is nothing to navigate yet) or crashes.

The standard fix is a small queue: if the app receives a deep link before it is ready to act on it, store the pending link, and once app initialization (router mounted, auth state resolved) completes, check for and process that pending link exactly once. This is a few lines of code, but skipping it is why “tapping a notification sometimes opens the right screen and sometimes just opens the home screen” is such a common, intermittent-feeling bug report — it is not actually random; it is a race between the link arriving and the app finishing startup, and it reproduces reliably once you know to test both a cold and a warm start separately.

There is a third situation worth distinguishing explicitly, sitting between the two: a backgrounded start, where the app was already running, was suspended when the user switched away, and is now being resumed by the same tap that delivered the link. Depending on the platform and how aggressively the OS reclaimed memory while the app was backgrounded, this can behave like a warm start (state intact, just needs to navigate) or quietly like a cold start (the process was actually killed, and the OS is relaunching it from scratch while pretending continuity to the user). Testing only the two obvious extremes — app freshly killed, or app sitting in the foreground — and skipping the backgrounded-and-resumed case is exactly how this category of bug slips past a team’s own manual testing and reaches real users first.

Both platforms ship a way to fire a deep link from the command line, which is far faster than generating a real shareable link every time you want to test a code change:

# Android (via adb, app already installed on device/emulator)
adb shell am start -W -a android.intent.action.VIEW \
  -d “priodemy://course/dsa-roadmap-beginners” com.priodemy.app

# iOS Simulator
xcrun simctl openurl booted “priodemy://course/dsa-roadmap-beginners”

These are standard, documented developer tools (part of the Android SDK platform-tools and Xcode command-line tools respectively), not a trick — and they are worth scripting into a small test checklist that covers a handful of representative link shapes (a valid one, a malformed one, one with query params, one that should fall back to home) rather than testing deep links only by hand, once, right before a release.

For the verified flavours specifically, there is a second layer worth testing beyond the link firing correctly: whether the OS actually trusts your verification file in the first place. Android exposes this through adb shell pm get-app-links com.priodemy.app, which reports whether each declared domain is verified, legacy-failure, or not verified at all — a much faster way to catch a broken assetlinks.json than waiting to notice the browser opening instead of the app during a manual test. There is no equally simple command-line check on iOS; the most reliable way to confirm a Universal Link is Apple’s own Associated Domains diagnostics inside Xcode’s device console when installing a fresh build, which is worth doing once per app rather than assuming the configuration is correct because it compiled without errors.

Common Pitfalls

A short list of the mistakes that account for most real-world deep linking bug reports:

  • Verification file served with the wrong content type or behind a redirect. Both assetlinks.json and apple-app-site-association have to be reachable directly over HTTPS with no redirect hop — a domain that redirects HTTP to HTTPS, or www to non-www, in a way that also redirects this specific path, can silently break verification.
  • Forgetting to re-register a new SHA-256 fingerprint in assetlinks.json after rotating or regenerating a signing setup — the App Link verification will keep failing until the file matches the certificate the currently published app is actually signed with.
  • Not handling the unknown-link case. Any link-parsing router should have an explicit fallback (usually: go to the home screen) for a shape it does not recognize, rather than crashing — app updates and marketing campaigns will eventually produce a link shape nobody tested.
  • Only testing warm start. As covered above, cold start is where the queueing bug actually shows up; it is easy to test only by tapping a link while already in the app during development.
  • Assuming the host segment is always a path segment. As the router example above showed directly, a custom-scheme URI parses its first segment as the host, not a path element — code that works for one link shape during a quick test can quietly fail for a different, equally valid shape later.
  • Hardcoding localhost or a staging domain into the verification file and forgetting to switch it for production — a surprisingly common release-day surprise when a team’s staging and production environments share most of their configuration by copy-paste.

Frequently Asked Questions

What is the real difference between a deep link and a universal link?

“Deep link” is the general umbrella term for any link that opens a specific screen in an app, including unverified custom URI schemes. “Universal link” is Apple’s specific name for the verified, https-based, domain-ownership-checked version of that idea on iOS. Android’s equivalent verified mechanism is called an App Link, not a universal link — the terms are platform-specific even though the underlying goal is the same.

Can a deep link work before the app is installed?

A verified App Link or Universal Link gracefully falls back to opening the URL in a browser if the app is not installed, which is often used to show a web page with an install prompt. Taking the user straight to the right in-app screen immediately after they install (often called deferred deep linking) requires extra plumbing — typically passing the original link’s intent through the install flow via a linking SDK — since the OS itself does not persist that context across an install by default.

Are deep links secure enough for something like a password reset?

The link itself is not a security mechanism — it is just a way to get the user to the right screen with a token attached. The actual security has to happen server-side: the token must be verified, checked for expiry, and ideally single-use, regardless of how the user arrived at the screen that submits it.

Why did my Android App Link open the browser instead of my app?

The most common causes are: the app is not installed (expected, correct fallback behaviour), the SHA-256 fingerprint in assetlinks.json does not match the certificate the installed app is actually signed with, the JSON file is not reachable at exactly /.well-known/assetlinks.json over HTTPS without a redirect, or Android has not re-fetched and re-verified the file since you last changed it.

Do I need both a custom scheme and App Links or Universal Links?

Most production apps ship both: the verified https form for anything shared externally (so it still works gracefully if the app is not installed), and a custom scheme for internal use cases like push notification payloads or in-app redirects where domain verification is unnecessary overhead.