Mobile Deep Linking & Universal Links — Chat Prompts
If your SaaS has a mobile app companion in 2026, you'll hit deep linking on day one of the mobile launch. The naive shape: links to the app open in the App Store ("download our app") and never get the user back to the right context. The right shape: a tap on https://yourapp.com/projects/123 opens your iOS or Android app DIRECTLY at that project — and gracefully falls back to the App Store install if the user doesn't have the app yet, then continues to that project AFTER install (deferred deep linking). Without deep linking, your mobile UX is broken; emails route users to the App Store front page; sharing links across platforms loses context; ad campaigns can't measure post-install activation.
Deep linking has three layers: standard URL routing in-app (custom URL schemes); universal links + app links (HTTPS URLs that open the app); and deferred deep linking (link → install → open at right place). Each layer adds complexity. Get it right and your mobile + web flows feel like one product. Get it wrong and 50% of users bounce when they hit a "would you like to open in app?" dialog.
This chat walks through implementing real deep linking: URL scheme design, Apple Universal Links + Android App Links setup, deferred deep linking with attribution, edge cases (universal-links-blocked-by-Gmail, browser-vs-in-app-browser handling), testing, and operational realities.
What you're building
- URL scheme design (consistent across web + mobile)
- iOS Universal Links setup (apple-app-site-association file)
- Android App Links setup (assetlinks.json, intent filters)
- Custom URL schemes as fallback (yourapp://)
- Deferred deep linking (link → install → continue)
- Smart banners ("Open in app" prompts)
- Attribution (which deep link drove install / activation)
- Email + SMS link patterns
- Sharing patterns (web → mobile; mobile → mobile)
- Operational testing + monitoring
1. Decide the scope BEFORE building
Help me decide what scope of deep linking to build.
Three increasingly-deep shapes:
LEVEL 0: NO DEEP LINKING (the simplest start)
- Mobile app users see "App Store" link from emails / web
- Tap → App Store; install → opens to home screen of app (lost context)
- Pros: zero engineering
- Cons: terrible UX; lost context; lower activation
- Right for: pre-product-launch only; never long-term
LEVEL 1: URL SCHEMES + STANDARD UNIVERSAL/APP LINKS
- Tap https://yourapp.com/path → opens app directly to that path
- Custom scheme yourapp://path also works
- Email + web links work in app context
- Pros: solid UX; covers 80% of use cases
- Cons: doesn't handle install-then-open flow (user without app loses context)
- Time to ship: 2-4 weeks for both iOS + Android
LEVEL 2: DEFERRED DEEP LINKING
- Tap link without app → App Store → install → app opens at the original deep-link target
- Requires server-side tracking layer (or third-party tool: Branch / AppsFlyer / Adjust)
- Pros: complete UX; install-to-activation conversion
- Cons: more engineering; usually a third-party tool
LEVEL 3: ATTRIBUTION + ANALYTICS
- Level 2 + tracking which deep link drove which install / activation
- Per-channel measurement (email vs SMS vs paid social)
- Required for paid mobile UA
- Pros: measurement; ROI per channel
- Cons: vendor cost (Branch / AppsFlyer / etc.); per-event pricing
DEFAULT FOR MOST B2B SaaS WITH MOBILE COMPANION:
- Year 1 (initial mobile launch): Level 1 (universal links + app links)
- Year 2+: Level 2-3 if running paid mobile UA or measuring channels
Don't:
- Skip Level 1 (universal links are mandatory in 2026)
- Pre-build Level 3 without paid UA spend justifying it
- Build deferred deep linking yourself (use Branch / AppsFlyer)
Output: scope decision; explicit "we are NOT building" boundary.
Output: scope statement.
2. Design the URL scheme
Before any platform setup, design URL patterns. They must work across:
- Web (yourapp.com routes)
- iOS (Universal Links: same URLs)
- Android (App Links: same URLs)
- Custom scheme fallback (yourapp:// for legacy / cross-app)
Universal pattern (same URLs everywhere):
Web URL → Resource → Mobile Action
https://yourapp.com/ → home → open home tab
https://yourapp.com/projects/123 → project detail → open project 123
https://yourapp.com/users/@alice → user profile → open profile
https://yourapp.com/share/abc123 → public share link → open share view
https://yourapp.com/auth/callback → OAuth callback → handle auth
https://yourapp.com/invite/xyz → workspace invite → open invite flow
Custom scheme equivalents:
yourapp://projects/123
yourapp://users/@alice
yourapp://share/abc123
URL design principles:
1. Same URL works on web + iOS + Android
2. URL is shareable (no auth tokens in URL)
3. URL is stable (don't break old links)
4. Hierarchical (matches resource model)
5. Lowercase; hyphens for separation
6. Avoid URL fragments for routing (#) — universal links don't always preserve them
Auth + private resources:
- /projects/123 requires user logged in
- If not logged in: route to login page; deep-link returned-to after login
- If user has no access: graceful "you don't have access" page
- Never include access tokens in URLs
OAuth callback URLs:
- yourapp://auth/callback for native OAuth
- Universal Link variant: https://yourapp.com/auth/native-callback
- Each OAuth provider has its own redirect_uri requirements
Implement:
1. URL scheme document (single source of truth)
2. Route table mapping URLs → mobile screens
3. Custom-scheme fallback for legacy / non-HTTPS contexts
4. Auth-required URL handling
5. Web routes that match exactly (so universal links work)
Output: URL design that works across platforms.
3. Implement iOS Universal Links
iOS Universal Links: HTTPS URLs that open your app instead of Safari.
Step 1: Configure Associated Domains in app
In Xcode → Signing & Capabilities → add Associated Domains:
- applinks:yourapp.com
- applinks:www.yourapp.com (if using www subdomain)
Step 2: Host apple-app-site-association (AASA) file
At https://yourapp.com/.well-known/apple-app-site-association
Content (JSON; no extension; Content-Type: application/json):
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": ["TEAMID.com.yourapp.app"],
"components": [
{
"/": "/projects/*",
"comment": "Match project URLs"
},
{
"/": "/users/*",
"comment": "Match user URLs"
},
{
"/": "/share/*",
"comment": "Match share URLs"
},
{
"/": "/invite/*",
"comment": "Match invite URLs"
}
]
}
]
},
"webcredentials": {
"apps": ["TEAMID.com.yourapp.app"]
}
}
CRITICAL:
- Served via HTTPS (no redirects allowed)
- Content-Type: application/json
- File must NOT have .json extension
- Cached aggressively by iOS; updates take 24-48hr
- Apple verifies the file when app installs / iOS reboots
Step 3: Handle the URL in your app
In SwiftUI / UIKit, handle continueUserActivity:
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return false }
// Parse URL and route to appropriate screen
return handleDeepLink(url: url)
}
func handleDeepLink(url: URL) -> Bool {
let path = url.path
let components = URLComponents(url: url, resolvingAgainstBaseURL: true)
switch path {
case let p where p.hasPrefix("/projects/"):
let projectId = String(p.dropFirst("/projects/".count))
navigateToProject(id: projectId)
return true
case let p where p.hasPrefix("/share/"):
let shareToken = String(p.dropFirst("/share/".count))
navigateToShare(token: shareToken)
return true
// ... more
default:
return false
}
}
Edge cases:
- User long-presses link → "Open in Safari" option appears (system feature; can't disable but can guide UX)
- App backgrounded → iOS resumes app; continueUserActivity called
- App not installed → URL opens in Safari; show smart banner
- iOS Mail app sometimes opens in-app browser instead of triggering universal link (workaround: smart banner)
Implement:
1. Associated Domains in Xcode
2. AASA file hosted correctly
3. continueUserActivity handler
4. Route → screen mapping
5. Auth-required-route handling (deep-link return after login)
6. Test scaffolding
Output: iOS deep linking that works.
4. Implement Android App Links
Android App Links: similar concept; different setup.
Step 1: Add intent filters in AndroidManifest.xml
<activity android:name=".MainActivity">
<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="yourapp.com" />
<data
android:scheme="https"
android:host="www.yourapp.com" />
</intent-filter>
<!-- Custom scheme fallback -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="yourapp" />
</intent-filter>
</activity>
The autoVerify="true" tells Android to verify the link via Digital Asset Links.
Step 2: Host assetlinks.json
At https://yourapp.com/.well-known/assetlinks.json
Content:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.yourapp.app",
"sha256_cert_fingerprints": [
"AB:CD:EF:..." // your release signing cert SHA-256
]
}
}]
Get fingerprint via: keytool -list -v -keystore your-release-key.jks
CRITICAL:
- Served via HTTPS
- Content-Type: application/json
- Use RELEASE signing cert (not debug)
- For Play App Signing: get fingerprint from Play Console → App signing
- Multiple fingerprints in array if you have separate debug + release
Step 3: Handle the intent in your app
In MainActivity onCreate / onNewIntent:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleDeepLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLink(intent)
}
private fun handleDeepLink(intent: Intent) {
val data = intent.data ?: return
val path = data.path ?: return
when {
path.startsWith("/projects/") -> {
val projectId = path.removePrefix("/projects/")
navigateToProject(projectId)
}
path.startsWith("/share/") -> {
val shareToken = path.removePrefix("/share/")
navigateToShare(shareToken)
}
// ...
}
}
Edge cases:
- Verification can fail if assetlinks.json wrong; debug via Android verification status: adb shell pm get-app-links com.yourapp.app
- Some Android browsers (Chrome) sometimes show "Open in app" disambiguation despite verification
- Custom scheme fallback important for in-app webviews (Slack, Discord, Gmail in Chrome)
Implement:
1. AndroidManifest.xml intent filters
2. assetlinks.json hosted correctly
3. Intent handler in MainActivity
4. Route → screen mapping
5. Verification testing
Output: Android deep linking that works.
5. Implement deferred deep linking (Level 2)
Deferred deep linking solves the "user without app" case.
Problem flow:
1. User taps https://yourapp.com/projects/123
2. App not installed
3. iOS / Android shows "App Store" page
4. User installs the app (10 seconds to 2 minutes elapse)
5. User opens app → home screen
6. CONTEXT LOST — they wanted project 123
Solution: server-side tracking that survives the install gap.
Approaches:
OPTION A: BUILD YOURSELF (DIY)
- When user taps link without app: redirect to app store with a tracking ID in URL parameter
- Track ID + intended deep link in your server DB (with IP / user-agent / timestamp)
- After install + first open: app calls your server with a fingerprint
- Match fingerprint to original tracking ID → return intended deep link
- App navigates to that deep link
Challenges:
- Fingerprinting is unreliable (IP rotation; browser fingerprint blocked)
- IP attribution wrong on shared networks
- Match window short (5-30 minutes)
- Apple/Google increasingly hostile to this approach
OPTION B: USE A LIBRARY (RECOMMENDED)
- Branch — most popular; rich SDK
- AppsFlyer OneLink — paired with attribution
- Adjust DeepLink — paired with attribution
- Singular DeepLink — adjacent
These tools handle:
- Matching install → original click via fingerprinting + Apple Search Ads attribution + iOS SKAdNetwork
- Long-window matching
- Attribution data (which campaign drove install)
- Native cross-platform SDKs
Branch sample integration:
iOS:
import BranchSDK
func application(_ application: UIApplication,
didFinishLaunchingWithOptions ...) {
Branch.getInstance().initSession(launchOptions: launchOptions) { params, error in
guard let params = params, error == nil else { return }
if let deepLinkUrl = params["+deeplink_path"] as? String {
handleDeepLink(path: deepLinkUrl)
}
}
}
Android: similar SDK call.
Server side (your code):
When user shares a link, generate Branch link:
POST /api/v1/url
{
"branch_key": "your_key",
"data": {
"$ios_url": "https://apps.apple.com/...",
"$android_url": "https://play.google.com/store/apps/...",
"$desktop_url": "https://yourapp.com/projects/123",
"+deeplink_path": "projects/123",
"channel": "email_invite",
"feature": "project_share"
}
}
Response: { "url": "https://yourapp.app.link/abc123" }
User shares this Branch URL. Branch handles:
- Desktop user: redirects to web URL
- Mobile user with app: opens app at projects/123
- Mobile user without app: App Store install → first open at projects/123
Pricing: Branch free up to 10K MAU; paid tiers scale.
DEFAULT recommendation for most SaaS:
- Use Branch (free tier real; works well)
- AppsFlyer / Adjust if you're already using them for attribution
Implement:
1. Pick library (Branch recommended)
2. SDK integration in iOS + Android apps
3. Server-side link generation API
4. Replace plain URLs with Branch links in:
- Email invites
- Share links
- Marketing campaigns
- SMS sends
5. Test full install flow
6. Monitor: deep-link match rate (target: 70%+)
Output: deferred deep linking that survives the install gap.
6. Smart banners (when app exists vs install)
Smart banners: web pages prompting "Open in app" or "Get the app".
iOS Smart App Banner (built-in):
In your web app's <head>:
<meta name="apple-itunes-app"
content="app-id=123456789, app-argument=https://yourapp.com/projects/123">
Apple shows a native banner at top of Safari. Tap → opens app at app-argument URL or App Store.
Limitations: Safari only. Chrome on iOS won't honor it.
Android Smart Banner (DIY):
No native equivalent. Build your own banner UI:
if (isMobileBrowser() && !isInApp()) {
showBanner({
title: 'Open in YourApp',
cta: hasAppInstalled() ? 'Open' : 'Get',
onClick: () => {
if (hasAppInstalled()) {
window.location.href = `yourapp://projects/${projectId}`
// Fallback to App Store after 2s if app didn't open
setTimeout(() => window.location.href = androidStoreUrl, 2000)
} else {
window.location.href = androidStoreUrl
}
},
})
}
Detecting "app installed": tricky because browsers don't expose this. Heuristics:
- User-Agent: looks for "in-app browser" patterns
- Timeout-based fallback (custom scheme attempt; if no transition, show install)
- Branch / AppsFlyer have detection libraries
Cross-browser banner UX:
- iOS Safari: native smart-app-banner
- iOS Chrome: DIY
- Android Chrome: DIY (or Branch's Smart Banner)
- Android Firefox: DIY
- In-app browsers (Instagram, Facebook, X, Slack): ALL DIY, often broken
In-app browser handling:
- Detect via user-agent
- Show alternative: "Open in browser to continue" CTA
- Or: use Branch's "Open in" library
Implement:
1. iOS Safari smart-app-banner
2. Android DIY smart banner
3. In-app browser detection
4. Banner UX with timing-based fallbacks
5. A/B testing the banner copy + CTA
Output: smart banners that drive install + open.
7. Email + SMS deep link patterns
Most deep links flow through email / SMS. Get this right.
Email patterns:
For invitations:
Subject: "Alice invited you to [Project]"
Body:
[Click here to open in YourApp] (deep link)
Don't have the app yet? Download it:
[App Store] [Google Play]
The deep link URL: https://yourapp.com/invite/[token]
- Mobile with app: opens app directly to invite acceptance
- Mobile without app: App Store / Play Store → install → continue (if Branch / Level 2)
- Desktop: opens web app
For notifications:
Subject: "New comment on [Project]"
CTA Button: "View comment"
URL: https://yourapp.com/projects/123/comments/456
Mobile-friendly:
- Single primary CTA (not multiple buttons competing)
- Web URL fallback always works (universal link OR web)
- Add ?utm_source=email&utm_campaign=notification for tracking
SMS patterns:
Short, single CTA. Character-conscious:
"You have a new project assigned: yourapp.com/projects/123 - Reply STOP to unsubscribe"
Considerations:
- Carriers may rewrite long URLs; use a domain shortener
- Some carriers block bit.ly / shortened URLs
- Your own domain (yourapp.com) is most reliable
Branch.io / similar tools generate links that work everywhere:
- yourapp.app.link/abc123 → resolves correctly per platform
Implementation:
1. Email-template engine includes deep links by default
2. SMS templates use Branch / shortener for reliability
3. UTM parameters for tracking
4. Test in: Gmail, Outlook, Apple Mail, in-app browser previews
5. Test: every email link works on iOS + Android + desktop
Output: emails + SMS that drive correct app behavior.
8. Operational testing + monitoring
Deep linking is fragile. Test continuously.
Test matrix (minimum):
iOS:
- Safari + universal link: app opens ✓
- Safari + universal link (long-press → "Open in Safari"): web opens ✓
- Mail.app + tap link: app opens ✓
- Slack mobile + tap link: in-app browser opens (suboptimal); detect + suggest "open in app"
- Gmail iOS + tap link: in-app browser opens
- Twitter/X iOS + tap link: in-app browser opens
- Custom scheme: opens app ✓
Android:
- Chrome + universal link: app opens (after autoVerify) ✓
- Gmail Android + tap link: app opens ✓
- Chrome incognito: may break verification
- Firefox: app opens ✓
- Samsung Internet: usually works
- Custom scheme: opens app ✓
Cross-platform:
- Desktop email → tap link: web opens (mobile-friendly fallback) ✓
- iOS user shares to Android user: link works on Android ✓
Automated testing:
- E2E tests on real devices (BrowserStack / AWS Device Farm)
- Test on every release
- Track deep-link click rate per channel
Monitoring metrics:
- Deep link click rate (out of email sends)
- Universal link → app conversion rate (how many universal-link taps actually open app)
- Deferred deep link match rate (Level 2; target: 70%+)
- Activation rate post-deep-link
- Errors per platform / per browser
Alerts:
- AASA file 404 / wrong content type → CRITICAL
- assetlinks.json missing → CRITICAL
- Deep-link match rate drops below threshold → HIGH
Common issues:
- AASA cache: Apple caches for 24-48h; updates lag
- Android verification: requires release-build cert; debug builds need separate config
- HTTPS redirect chains: AASA / assetlinks.json should NOT redirect (Apple/Google won't follow)
- CDN-cached AASA with wrong content-type: blank page
- Add new deep link patterns to AASA: deploy 24-48h before launch
Implement:
1. Test matrix automation
2. Monitoring dashboards
3. Alerts for AASA / assetlinks issues
4. Deep-link conversion funnel tracking
5. Regular cross-platform regression testing
Output: deep linking that doesn't silently break.
9. Edge cases + operational realities
Walk me through:
1. AASA / assetlinks.json deployment
- File must be reachable BEFORE app uses universal links
- Deploy file 24-48h before app release that uses new patterns
- Test from production HTTPS endpoint
- No redirects; no auth wall
2. Subdomains
- AASA / assetlinks per domain
- yourapp.com and api.yourapp.com need separate files
- For multi-domain (white-label): each customer's custom domain needs them too (advanced)
3. White-label customers with custom domains
- Each custom domain needs AASA / assetlinks
- App's Associated Domains list grows
- Practical limit: ~20 domains per app
- Beyond: Branch-style shortlink approach
4. iOS App Clips deep linking
- App Clips are smaller; have their own deep-link handling
- AASA includes appclip-specific entries
5. Browser extensions / in-app webviews stripping params
- Some browsers strip query strings from links
- Test your patterns; avoid relying on query params for routing
- Use path-based routing primarily
6. URL-shortener as link generator
- Don't use bit.ly for app deep links (loses universal-link signal)
- Use Branch / your own domain
7. Apple's anti-fingerprinting (privacy)
- Apple has tightened deferred-deep-link fingerprinting (iOS 14.5+)
- Branch / AppsFlyer adapt; their match rate has dropped from 95% to 60-80%
- Plan for that
8. App not handling new deep-link path
- Old app version doesn't recognize new path → falls through to web
- Defensive: web works as fallback; app handles known paths only
9. Auth-required deep links
- User taps link → app opens → user not logged in → redirect to login → after login, navigate to deep link
- Save deep-link URL in app state during login flow
10. Workspace-context deep links
- Deep link routes user to /projects/123
- User has multiple workspaces; project belongs to workspace X
- App must auto-switch to workspace X
- Or: prompt "switch to workspace X to view this project?"
11. Sharing a deep link to non-customer
- Recipient has app: opens correctly
- Recipient doesn't have app: deferred deep link kicks in → install → land on share page
- Recipient on desktop: web app opens to share page
12. Emoji or special chars in URL
- URL-encode properly; iOS / Android handle differently
- Test: avoid emoji in deep-link path components
13. New iOS / Android version breaks something
- Track iOS / Android beta releases for deep-link changes
- Test with beta OS before releases
14. SDK upgrade: Branch / AppsFlyer
- Major SDK upgrades require regression testing
- Don't upgrade right before a launch
For each: code change + testing impact + ops alert.
Output: deep linking that survives real-world conditions.
10. Recap
What you've built:
- URL scheme document (single source of truth)
- iOS Universal Links (AASA + Associated Domains)
- Android App Links (assetlinks + intent filters)
- Custom-scheme fallbacks
- Deferred deep linking (Branch / AppsFlyer / Adjust)
- Smart banners (iOS + Android)
- Email + SMS link patterns
- Auth-required deep-link handling
- Test matrix automation
- Monitoring + alerts
- Operational runbooks for common issues
What you're explicitly NOT shipping in v1:
- App Clip deep links (defer; few teams need)
- Universal Link for Apple Watch (defer)
- Sharing extensions (defer; separate iOS API surface)
- Per-customer-domain Universal Links (defer; only for white-label)
- Cross-app deep linking (yours → competitor's app) (skip; usually not your problem)
Ship Level 1 in 2-4 weeks alongside mobile launch. Add Level 2 (deferred) when paid mobile UA starts. Add Level 3 (attribution) when measurement justifies vendor cost.
The biggest mistake teams make: skipping AASA / assetlinks setup. Without it, you have NO universal links — and "universal links" requires both. Ship the file BEFORE the app.
The second mistake: building deferred deep linking yourself. Branch / AppsFlyer's free tiers are generous; their fingerprinting is better than yours.
The third mistake: not testing in in-app browsers. 50%+ of mobile email reads happen in Slack / Gmail / Twitter in-app browsers. They behave differently from Safari / Chrome standalone.
See Also
- Calendar Integrations — adjacent OAuth-based mobile pattern
- OAuth Provider Implementation — adjacent (auth flow on mobile)
- Public Share Links & Permissioned Sharing — pairs (share links on mobile)
- Mobile Push Notifications — pairs (deep links inside push)
- Email Template Implementation — pairs for email deep links
- Slugs & URL Handling — depended-upon URL discipline
- SSO / Enterprise Auth — adjacent identity flow
- Workspace Branding & Custom Domains — pairs for custom-domain deep links
- Multi-Tenancy — workspace context for deep-link routing
- Activity Feed & Timeline — adjacent (events drive notifications drive deep links)
- Mobile Attribution Platforms (Reference) — adjacent attribution discipline
- App Store Optimization Tools (Reference) — adjacent mobile-marketing