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
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.
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
1) Link types: custom scheme vs verified links vs web fallback
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)
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.jsonathttps://example.com/.well-known/assetlinks.json - Add an
intent-filterfor 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)
- Normalize: parse URL, lower-case host, remove obvious junk params
- Validate: allowlist hosts + routes; reject unknown paths
- Extract intent: map path to a route enum (product, invite, reset)
- Gate: if route requires auth, store “pending link” and continue after login
- Navigate: open target screen; pass only validated parameters
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.
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.jsonhosted 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
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.