Mobile Development · Deep Links

Deep Links That Work: Universal Links, App Links, and Edge Cases

Make marketing links open the right screen every time.

Reading time: ~8–12 min
Level: All levels
Updated:

Make marketing links open the right screen every time. The tricky part isn’t “how to create a deep link” — it’s making it reliable across iOS/Android, browsers, in-app webviews, redirects, tracking parameters, and weird edge cases like “user previously chose Safari”. This guide gives you a practical setup for Universal Links and Android App Links, plus a debugging playbook you can use when things inevitably go sideways.


Quickstart

If you want deep links that behave predictably in marketing campaigns, onboarding emails, and QR codes, do these steps in order. They’re the highest leverage items that prevent the classic “it works on my phone” problem.

1) Pick one canonical link format

Decide what your “shareable” URL looks like (domain + path structure) and stick to it. Deep linking gets messy when every team invents a new link type.

  • Use HTTPS links as the primary format (not custom schemes)
  • Define a simple route map: /p/:id, /invite/:code, /reset/:token
  • Keep stable routes for marketing + email + QR codes

2) Verify domain ownership on both platforms

Universal Links and App Links are “verified deep links”: the OS only opens the app if the domain is cryptographically tied to your app.

  • iOS: host an AASA file at /.well-known/apple-app-site-association
  • Android: host Digital Asset Links at /.well-known/assetlinks.json
  • Serve both over HTTPS, publicly accessible, without auth

3) Implement a single “deep link router” in the app

Don’t handle deep links in five places. Centralize parsing, validation, and navigation so behavior is consistent.

  • Parse URL → route → required params
  • Validate (signature/token expiry/allowed hosts)
  • Decide: open screen now vs after login

4) Add a web fallback that respects privacy + tracking

Not everyone has the app installed. Your link should still do something useful and not break attribution.

  • Show content on web when possible
  • Offer “Open in app” + “Get the app” CTA
  • Preserve UTM params and your internal context
A reliable default

Treat the URL as the source of truth. Marketing creates a URL; the OS routes to the app when installed; the web page is the fallback. That’s the most robust approach across platforms and ad networks.

Minimum test matrix (do this before you ship links)

Scenario Expected behavior What you’re validating
App installed + tap link in Safari/Chrome Opens app to correct screen Domain verification + routing
App not installed Opens web fallback (with CTA) Campaign still works
Tap link in in-app browser (Instagram/Facebook/etc.) Usually opens web (sometimes app) Edge-case expectations
Paste link into notes / messages Opens app (if verified) iOS “user choice” and OS caching

Overview

“Deep links” are just links that open a specific location inside your app (a product, a promo screen, a reset flow). The hard part is that there are multiple systems involved: the OS, the browser (or in-app webview), your website, and your app’s routing and auth flow.

What this post covers

  • Universal Links (iOS): how they work, what the AASA file does, and why they sometimes “stop working”.
  • Android App Links: assetlinks.json, intent-filters, auto verification, and resolver behavior.
  • Edge cases: redirects, tracking parameters, in-app browsers, user overrides, and caching.
  • A practical workflow: a repeatable setup + debugging checklist for real teams.
Deep links are a product surface

Treat deep links like an API contract: they’ll be shared, stored in emails, scanned from posters, and opened months later. Small breaking changes (routes, redirects, missing fallbacks) become expensive very fast.

Core concepts

There are three common ways to deep link into an app. They’re not interchangeable — each has different tradeoffs for reliability, security, and user experience.

Type Example Pros Cons / gotchas
Custom URL scheme myapp://product/123 Simple; works without domain setup Not verified; can be hijacked by another app; often blocked in webviews; no web fallback
Universal Links (iOS) https://example.com/p/123 Verified; opens app seamlessly; safe default for marketing Requires AASA; user can override to open in Safari; caching can confuse debugging
Android App Links https://example.com/p/123 Verified; avoids chooser when verified; supports multiple apps (with rules) Requires assetlinks + correct cert fingerprint; verification varies by device/OEM
Web fallback page https://example.com/p/123 Always works; supports SEO; supports non-app users Needs “Open in app” UX; must preserve params; avoid breaking redirects

2) The mental model: “OS decides, app routes”

Here’s the simplest mental model that prevents 80% of deep link confusion:

  • The URL is clicked. (From Safari, Chrome, mail client, QR scan, in-app browser.)
  • The OS tries to verify the domain. (AASA on iOS; assetlinks on Android.)
  • If verified + allowed, the OS opens your app. Otherwise it stays in the browser.
  • Your app routes the URL to a screen. (And handles login, missing params, expired tokens.)

3) Link contracts: routes, params, and versioning

Treat deep links like a stable interface. If marketing sends /invite/ABCD today, you should be confident it still works after three releases and a backend rewrite.

Design rules that age well

  • Prefer short, readable paths over query-only links
  • Keep IDs in the path; keep tracking in query params
  • Never require “secret” context that only the app knows
  • Make invalid/expired links degrade gracefully

Security basics (don’t skip)

  • Validate host and path (reject unknown domains)
  • Use short-lived tokens for sensitive actions (password reset)
  • Don’t put long-term secrets in query params
  • Log and rate limit abuse (especially public invite links)
One common misconception

Universal Links / App Links don’t magically solve routing. They solve trusted app opening. You still need a robust in-app router and a sane fallback for users without the app.

Step-by-step

This is a practical setup you can copy into a real project. It assumes you want one HTTPS URL that: opens the app when installed, opens the website when not, and behaves reasonably in messy environments (tracking links, webviews).

Step 1 — Define your deep link route map

Start with 5–10 routes that cover your core product and campaigns. Keep it boring. Boring is reliable.

  • Content: /p/:id, /c/:slug
  • Onboarding: /invite/:code, /welcome
  • Account: /reset/:token, /verify-email/:token
  • Campaigns: /promo/:name (plus UTM params)

Step 2 — iOS Universal Links (AASA + entitlement)

Universal Links require two pieces: (1) an entitlement in the app that says “I handle these domains”, and (2) a hosted apple-app-site-association file that proves the domain agrees.

iOS checklist

  • Enable Associated Domains capability
  • Add applinks:example.com (and optionally subdomains)
  • Host AASA at https://example.com/.well-known/apple-app-site-association
  • Make sure it’s reachable with 200 OK over HTTPS

Route control

The AASA file lets you declare which paths should open the app. Use it to avoid accidentally taking over your whole website.

  • List only the paths your app supports
  • Keep marketing/SEO pages on the web if needed
  • Update the file when you add new deep link routes

Example AASA file (replace the IDs with your real Team ID + bundle ID):

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "ABCD123456.com.example.myapp",
        "paths": [
          "/p/*",
          "/invite/*",
          "/reset/*",
          "/promo/*"
        ]
      }
    ]
  }
}

Practical note: keep your AASA file public (no auth), served over HTTPS, and avoid “smart” hosting setups that add redirects. If your file is missing or inaccessible, iOS silently falls back to the web.

Step 3 — Android App Links (assetlinks + intent-filter)

Android App Links also require two sides: (1) a verified statement on the domain (Digital Asset Links), and (2) an intent-filter that declares which URLs your app can handle.

Android checklist

  • Choose your production domain (avoid mixing prod/stage)
  • Host assetlinks.json at https://example.com/.well-known/assetlinks.json
  • Add an intent-filter for your routes
  • Enable verification with android:autoVerify="true"

Common verification pitfalls

  • Wrong signing certificate fingerprint (debug vs release)
  • Wrong package name in assetlinks
  • Assetlinks served from the wrong host / path
  • Multiple apps claim the same domain (chooser appears)

Example Digital Asset Links file (replace package + SHA-256 fingerprint):

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.myapp",
      "sha256_cert_fingerprints": [
        "12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF"
      ]
    }
  }
]

Example AndroidManifest intent-filter (keep it as tight as your route map):

<activity
    android:name=".DeepLinkActivity"
    android:exported="true">

    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />

        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data
            android:scheme="https"
            android:host="example.com"
            android:pathPrefix="/p" />

        <data
            android:scheme="https"
            android:host="example.com"
            android:pathPrefix="/invite" />

        <data
            android:scheme="https"
            android:host="example.com"
            android:pathPrefix="/reset" />

        <data
            android:scheme="https"
            android:host="example.com"
            android:pathPrefix="/promo" />
    </intent-filter>
</activity>

Step 4 — Build the in-app deep link router (and don’t trust the link)

Once the OS opens your app, your job is to safely interpret the URL and get the user to the right place. A good router protects you from broken links and makes onboarding flows resilient.

A sane routing flow (works for iOS + Android)

  1. Normalize: parse URL, lower-case host, remove obvious junk params
  2. Validate: allowlist hosts + routes; reject unknown paths
  3. Extract intent: map path to a route enum (product, invite, reset)
  4. Gate: if route requires auth, store “pending link” and continue after login
  5. Navigate: open target screen; pass only validated parameters
Pending deep links are normal

Many deep links require context (logged-in user, loaded profile, fetched data). Store the deep link intent, complete the prerequisite flow, then resume. This avoids “link opens app but does nothing”.

Step 5 — Handle the real-world edge cases

Deep link reliability is mostly about edge cases. You can’t control every browser or ad network, but you can design for graceful behavior. Here are the patterns that show up most often in production.

Redirects + link shorteners

Marketing loves link shorteners. OS verification does not. If you can, keep the final tap URL on your verified domain.

  • Prefer direct links: https://example.com/p/123
  • If you must redirect, keep it minimal and consistent
  • Avoid chaining multiple 302s through third-party domains

In-app browsers (webviews)

Some apps open links inside their own browser. This can change whether the OS offers to open your app. Plan for “web fallback first” behavior.

  • Make the web page useful (not just a blank redirect)
  • Offer an explicit “Open in app” button
  • Keep the same URL visible for copy/share

Tracking parameters

UTMs and campaign params are fine — just don’t let them break routing. Your router should ignore unknown query params.

  • Allowlist required params for sensitive flows
  • Never put secrets in UTMs
  • Preserve attribution server-side if possible

User overrides + caching

Sometimes users choose to open a link in the browser. iOS can remember that preference for that domain/path. Treat it as a UX reality, not a “bug”.

  • Provide an “Open in app” CTA on the web page
  • Don’t assume every tap will open the app
  • When debugging, test on a fresh install/device if needed

Common mistakes

These are the mistakes behind most “deep links are flaky” stories. The fix is usually simple — once you know where to look.

Mistake 1 — Treating custom schemes as a marketing solution

myapp:// links often fail in webviews and aren’t verified (another app can register the same scheme).

  • Fix: use HTTPS Universal Links / App Links as the primary link format.
  • Fix: use custom schemes only as a secondary “Open in app” fallback when needed.

Mistake 2 — Hosting AASA / assetlinks behind redirects or auth

The OS must fetch these files unauthenticated. If it can’t, it silently falls back to the browser.

  • Fix: serve files publicly over HTTPS at /.well-known/….
  • Fix: confirm you get 200 OK and the expected content from a normal browser.

Mistake 3 — Claiming the whole site without matching app routes

If your app declares it can open everything but only supports a few routes, users land in dead ends.

  • Fix: restrict AASA paths and Android intent-filters to supported routes.
  • Fix: add a safe “unknown link” screen (and optionally open the web page).

Mistake 4 — Mixing debug and release signing on Android

App Links verification depends on the signing certificate fingerprint. Debug builds and Play Store builds differ.

  • Fix: put the correct SHA-256 fingerprint(s) into assetlinks.json.
  • Fix: test verification using the same build type you plan to ship.

Mistake 5 — Assuming “tap link” always triggers OS app opening

In-app browsers, copy/paste flows, and user choices can keep you on the web even if everything is set up correctly.

  • Fix: design a strong web fallback that’s useful on its own.
  • Fix: add an explicit “Open in app” CTA and keep URLs shareable.

Mistake 6 — Letting tracking params break routing

Some routers fail when unknown query params appear (UTM, click IDs, etc.). That’s a self-inflicted outage.

  • Fix: parse path → route first, then read only the params you need.
  • Fix: keep attribution logic separate from navigation logic.
The sneakiest failure mode

You can have correct domain verification and still “fail” because your app router crashes on unexpected input. Treat deep links as untrusted input — validate and handle errors like you would for any public endpoint.

FAQ

Why does my Universal Link open Safari instead of the app?

Most commonly: the OS couldn’t verify the domain (AASA missing/unreachable), the link doesn’t match an allowed path, or the user previously chose to open that link/domain in the browser. Start by checking AASA availability (200 OK over HTTPS), then confirm your paths match what the app actually supports, and finally test on another device/build to rule out cached user preference.

Why do Android App Links show a chooser instead of opening my app directly?

A chooser typically means the link isn’t verified, or multiple apps can handle the same domain. Ensure android:autoVerify="true" is set, assetlinks.json is correct for your package and signing fingerprint, and only one installed app claims the same host/path. Verification can also vary by device/OEM, so test across at least one “clean” device.

Can I use a URL shortener for deep links?

You can, but it often reduces reliability because the OS verification is tied to the final domain and some environments handle redirects differently. For best results, use your verified domain as the tap target (even if it’s a short path), and do tracking on your own domain.

How do I handle deferred deep linking (user installs the app first)?

The OS-level verified links don’t automatically “remember” the clicked URL across install in a universal way. The practical approach is: store campaign context on the web (or backend) keyed by a short code, send users to the store with that code, and then redeem it on first launch (or use a dedicated deep-link/attribution provider if you need cross-network attribution at scale). The key is to keep the deep link intent representable as a short, redeemable token.

Should my deep link routes be the same on web and in-app?

Ideally yes. One URL that works everywhere is the simplest model: web users see web content; app users see the equivalent in-app screen. If you need different behavior, keep it explicit (different paths) rather than “magic” rules that vary by platform.

What’s the best way to debug a broken deep link quickly?

Reduce variables: use a direct HTTPS link (no shorteners), confirm the verification files are reachable (public, 200 OK), test in a standard browser (Safari/Chrome) instead of an in-app browser, and log the incoming URL in the app router. If it works on one device but not another, suspect caching/user choice or signing/verification differences.

Cheatsheet

Keep this as your “ship-ready” checklist. Deep links fail when any layer is slightly off — this list keeps you honest.

Build checklist (one-time setup)

  • Choose one canonical HTTPS domain for marketing links
  • Define a stable route map (paths + required params)
  • iOS: Associated Domains enabled + correct applinks: entries
  • iOS: AASA hosted at /.well-known/apple-app-site-association
  • Android: intent-filters match only supported paths
  • Android: assetlinks.json hosted at /.well-known/assetlinks.json
  • Central deep link router (parse → validate → route → navigate)
  • Web fallback page that preserves query params and offers CTA

Debug checklist (when it breaks)

  • Use a direct link (no redirect chain) and reproduce
  • Confirm verification files are reachable publicly (200 OK)
  • Confirm paths in AASA / intent-filters include the link’s path
  • Check Android signing fingerprint matches the installed build
  • Test outside in-app browsers (Safari/Chrome first)
  • Log incoming URL + routing decision in the app
  • Try another device/build to rule out cached user preference
Deep link success criteria

A deep link is “done” when it (1) opens the correct screen when installed, (2) has a useful web fallback when not, (3) doesn’t break with tracking params, and (4) behaves acceptably in common in-app browsers.

Wrap-up

Deep links that work aren’t about hacks — they’re about verification, a stable URL contract, and a predictable router. If you keep one canonical HTTPS format, host the verification files correctly, and design for messy environments (webviews, redirects, user preference), your links stop being “flaky” and start being a reliable growth and UX surface.

Next actions

  • Write your route map and decide what the web fallback should display
  • Set up AASA + assetlinks on your production domain
  • Centralize routing and add safe handling for unknown/expired links
  • Run the test matrix (installed vs not installed; standard browser vs webview)

If you want to go deeper: treat deep links as part of your security surface (tokens, validation), and as part of your performance surface (fast open, no “loading forever”). The best links feel invisible: tap → correct screen → done.

Quiz

Quick self-check (demo). This quiz is auto-generated for mobile / development / deep.

1) What’s the most reliable “primary” format for marketing deep links across iOS and Android?
2) Where should the iOS apple-app-site-association (AASA) file be hosted for Universal Links?
3) On Android, what usually causes an App Link to show a chooser instead of opening your app directly?
4) What’s the best product-friendly approach to handle “app installed” vs “app not installed”?