Deep links let you open specific content inside a mobile app directly from a URL. Instead of landing on a generic home screen, users go straight to a product page, article, or profile. This guide covers everything from basic custom URL schemes to verified platform links.
Key Takeaways
- 1Deep links navigate users to specific in-app content, improving UX by 2-3x engagement
- 2Custom URL schemes (myapp://) work everywhere but show disambiguation dialogs
- 3Universal Links (iOS) and App Links (Android) open apps directly without prompts
- 4Deferred deep links work even when the app isn't installed yet
- 5Always provide web fallbacks for users without your app installed
Types of Deep Links
Not all deep links are created equal. This section compares the three main approaches: custom URL schemes, Universal Links (iOS), and App Links (Android). Understanding the trade-offs helps you choose the right approach for your use case.
There are three main approaches to deep linking, each with different trade-offs:
The table below compares all three approaches. We'll cover each in detail in the following sections.
| Type | Format | Pros | Cons |
|---|---|---|---|
| Custom URL Scheme | myapp://path | Simple to implement, works everywhere | Shows app chooser, no fallback |
| Universal Links (iOS) | https://example.com/path | No prompt, web fallback | Requires server config, iOS only |
| App Links (Android) | https://example.com/path | No prompt, web fallback | Requires server config, Android only |
Custom URL Schemes
Custom URL schemes have been around since the early days of mobile apps. They're simple to implement but have significant limitations for production use. This section covers the basics and explains when (and when not) to use them.
Custom URL schemes are the simplest form of deep linking. You define a unique scheme (like myapp://) that opens your app.
myapp://products/12345?ref=emailURL Scheme Structure
myapp://products/12345?ref=email&utm_source=newsletter
│ │ │ └─ Query parameters (passed to app)
│ │ └─ Resource ID
│ └─ Path (in-app route)
└─ Custom scheme (your app identifier)iOS Custom URL Scheme
Register your scheme in Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.example.myapp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
</dict>
</array>Handle incoming URLs in your AppDelegate or SceneDelegate:
// SceneDelegate.swift
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }
// Parse the URL
let path = url.path // "/products/12345"
let query = url.query // "ref=email"
// Route to appropriate screen
if path.hasPrefix("/products/") {
let productId = String(path.dropFirst("/products/".count))
navigateToProduct(id: productId)
}
}
// For older apps using AppDelegate
func application(_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
handleDeepLink(url: url)
return true
}The iOS code shows both the modern SceneDelegate approach and the legacy AppDelegate method. In the handler, you parse the URL to extract the path and parameters, then route to the appropriate screen. Always handle unexpected paths gracefully—deep links can come from anywhere.
Android Custom URL Scheme
Register your scheme in AndroidManifest.xml:
<activity android:name=".MainActivity">
<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="myapp" />
</intent-filter>
</activity>Handle incoming URLs in your Activity:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent?.data?.let { uri ->
val path = uri.path // "/products/12345"
val ref = uri.getQueryParameter("ref") // "email"
when {
path?.startsWith("/products/") == true -> {
val productId = path.removePrefix("/products/")
navigateToProduct(productId)
}
path?.startsWith("/users/") == true -> {
val userId = path.removePrefix("/users/")
navigateToUser(userId)
}
}
}
}The Android implementation uses Intent filters declared in the manifest. The code in onCreate extracts the URI from the intent and parses it. Note the null-safe calls with ?.let—deep link data may be missing if the activity was launched normally.
Custom Scheme Limitations
Custom URL schemes show an "Open in App" dialog and can be hijacked by other apps registering the same scheme. They also don't work if the app isn't installed—users see an error. For production apps, use Universal Links or App Links instead.
Given these limitations, Universal Links and App Links are the better choice for production apps. Let's start with iOS.
iOS Universal Links
Universal Links are Apple's recommended approach for deep linking. They provide a seamless experience—no prompts, automatic web fallback, and protection against scheme hijacking. This section covers the complete setup process.
Universal Links use standard HTTPS URLs that open your app directly without any prompt. If the app isn't installed, the URL opens in Safari.
https://example.com/products/12345"Universal links offer a seamless experience as users open your app directly from URLs."
Setup Requirements
Universal Links require coordination between your web server and your app. The table below outlines the three required steps—missing any one will cause links to fail silently.
| Step | Location | Purpose |
|---|---|---|
| 1. Apple App Site Association | Your web server | Tells iOS which paths open your app |
| 2. Associated Domains | Xcode entitlements | Links your app to your domain |
| 3. App Delegate handler | Your iOS app | Routes incoming URLs |
Apple App Site Association (AASA)
Host this JSON file at /.well-known/apple-app-site-association (no file extension):
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": ["TEAMID.com.example.myapp"],
"components": [
{
"/": "/products/*",
"comment": "Product pages"
},
{
"/": "/users/*",
"comment": "User profiles"
},
{
"/": "/orders/*",
"?": { "tracking": "?*" },
"comment": "Order tracking with query params"
}
]
}
]
}
}The AASA file tells iOS which URL paths should open your app. The components array uses pattern matching—/products/* matches any product URL. The appIDs value combines your Team ID (from Apple Developer Portal) with your bundle identifier.
The file must be served over HTTPS with Content-Type: application/json.
Xcode Configuration
Add the Associated Domains capability and add your domain:
# In Xcode → Signing & Capabilities → Associated Domains
applinks:example.com
applinks:www.example.comHandling Universal Links
// SceneDelegate.swift
func scene(_ scene: UIScene,
continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
// Parse the URL path
let components = URLComponents(url: url, resolvingAgainstBaseURL: true)
let path = components?.path ?? ""
// Route to the appropriate view
if path.hasPrefix("/products/") {
let productId = String(path.dropFirst("/products/".count))
navigateToProduct(id: productId)
} else if path.hasPrefix("/users/") {
let userId = String(path.dropFirst("/users/".count))
navigateToProfile(userId: userId)
}
}
// SwiftUI with onOpenURL
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
handleDeepLink(url)
}
}
}
}The SwiftUI onOpenURL modifier is the cleanest approach for new apps. For UIKit apps, the SceneDelegate method handles Universal Links. Note that Universal Links come through userActivity, not the regular URL handlers—this is a common source of confusion.
For a detailed implementation guide, see iOS Universal Links Guide.
Android has its own equivalent system called App Links. Let's see how it compares.
Android App Links
App Links are Android's verified deep linking system, introduced in Android 6.0. Like Universal Links, they use HTTPS URLs and require domain verification. This section covers the setup process and key differences from iOS.
App Links are Android's equivalent to Universal Links. They use verified HTTPS URLs that open your app directly.
Setup Requirements
The setup is similar to iOS: a verification file on your server and configuration in your app. The table below outlines the steps.
| Step | Location | Purpose |
|---|---|---|
| 1. Digital Asset Links | Your web server | Verifies app ownership of domain |
| 2. Intent filters with autoVerify | AndroidManifest.xml | Declares which URLs open your app |
| 3. Activity handler | Your Android app | Routes incoming URLs |
Digital Asset Links File
Host this JSON file at /.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
]
}
}
]AndroidManifest Configuration
<activity android:name=".MainActivity" android:exported="true">
<!-- App Links intent filter -->
<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="/products" />
</intent-filter>
<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="/users" />
</intent-filter>
</activity>The autoVerify="true" attribute triggers Android's verification process. When your app is installed, Android fetches your assetlinks.json file and verifies that your app's signing certificate matches. This verification is what allows your app to open links without the disambiguation dialog.
For a detailed implementation guide, see Android App Links Guide.
Universal Links and App Links work great when users already have your app. But what about users who don't? That's where deferred deep links come in.
Deferred Deep Links
Deferred deep links are essential for marketing campaigns. They preserve context through the app store install process, ensuring users land on the right content even when they didn't have your app. This section explains how they work and the services that provide them.
Deferred deep links work even when the app isn't installed. The user is taken to the app store, and after installation, they're routed to the original destination.
How Deferred Deep Links Work
The flow involves storing context before the app store redirect and retrieving it after installation. The table below walks through each step.
| Step | Action |
|---|---|
| 1 | User clicks deep link on web |
| 2 | Service stores link context (fingerprint, referrer, etc.) |
| 3 | User is redirected to App Store / Play Store |
| 4 | User installs and opens app |
| 5 | App queries service for stored context |
| 6 | App navigates to original destination |
Deferred Deep Link Services
Implementing deferred deep links from scratch is complex. Most apps use third-party services:
| Service | Key Features |
|---|---|
| Firebase Dynamic Links | Free, cross-platform, Google ecosystem integration |
| Branch.io | Advanced attribution, A/B testing, enterprise features |
| AppsFlyer OneLink | Attribution-focused, marketing analytics |
| Adjust | Fraud prevention, attribution, deep linking |
See our Firebase Dynamic Links Guide for implementation details.
With the linking mechanisms covered, let's discuss how to design your deep link URL structure for maximum usability.
Deep Link URL Structure
Good URL structure makes deep links predictable and debuggable. This section covers URL design patterns and using query parameters effectively. Design your deep link URLs the same way you'd design your web URLs—consistency reduces confusion.
Well-designed deep link URLs mirror your app's navigation structure:
# Resource-based paths (recommended)
https://example.com/products/12345
https://example.com/users/johndoe
https://example.com/orders/ABC123/tracking
# Feature-based paths
https://example.com/checkout?cart=xyz
https://example.com/search?q=shoes&category=men
# Action-based paths
https://example.com/verify-email?token=abc123
https://example.com/accept-invite?code=XYZ
# Tab or section navigation
https://example.com/app/settings/notifications
https://example.com/app/profile/editUsing Query Parameters
Query parameters pass additional context without changing the route:
# Attribution tracking
https://example.com/products/123?utm_source=email&utm_campaign=sale
# Referral programs
https://example.com/signup?ref=USER123&bonus=true
# Feature flags or experiments
https://example.com/checkout?variant=b&promo=SAVE20
# Contextual navigation
https://example.com/products/123?from=wishlist&position=3Query parameters are perfect for data that doesn't affect routing—analytics tracking, referral codes, feature flags, and navigation context. Keep the path structure simple and put modifiers in query parameters.
With your URLs designed, you need to test them. Deep link testing has unique challenges because it involves multiple systems.
Testing Deep Links
Deep links can fail silently or behave differently across environments. This section provides command-line tools and validation services to test your implementation thoroughly before shipping.
Testing on iOS
# Test custom URL scheme
xcrun simctl openurl booted "myapp://products/12345"
# Test Universal Link
xcrun simctl openurl booted "https://example.com/products/12345"
# Verify AASA file
curl -I https://example.com/.well-known/apple-app-site-association
# Check AASA content
curl https://example.com/.well-known/apple-app-site-association | jqTesting on Android
# Test custom URL scheme
adb shell am start -W -a android.intent.action.VIEW \
-d "myapp://products/12345" com.example.myapp
# Test App Link
adb shell am start -W -a android.intent.action.VIEW \
-d "https://example.com/products/12345" com.example.myapp
# Verify App Links status
adb shell pm get-app-links com.example.myapp
# Check assetlinks.json
curl https://example.com/.well-known/assetlinks.json | jqThe simulator and ADB commands let you test deep links without needing actual links in emails or web pages. The pm get-app-links command shows the verification status of your App Links—if it shows "verified", your configuration is correct.
Validation Tools
| Platform | Tool | URL |
|---|---|---|
| iOS | AASA Validator | https://branch.io/resources/aasa-validator/ |
| Android | Digital Asset Links Tool | https://developers.google.com/digital-asset-links/tools/generator |
| Both | Branch Link Validator | https://branch.io/resources/link-validator/ |
Common Issues
Deep links fail for many reasons, and the errors aren't always obvious. This section covers the most common issues developers encounter and how to diagnose them.
Universal Links not working
Check: AASA file accessible over HTTPS, no redirects, correct Content-Type header, Team ID matches, app capability enabled.
App Links showing browser
Check: assetlinks.json accessible, SHA256 fingerprint correct (both debug and release), autoVerify="true" set, fresh install to trigger verification.
Deep links work in dev but not prod
Check: Production certificate fingerprint in assetlinks.json, production Team ID in AASA, server configuration (CDN/proxy not blocking).
Links open website instead of app
User may have disabled app link handling in settings. On iOS, long-press the link and choose "Open in App." On Android, check app link settings in system settings.
Best Practices
Following these practices will save you debugging time and create a better user experience. Each recommendation comes from real-world implementation challenges.
| Practice | Why |
|---|---|
| Use HTTPS URLs over custom schemes | Better UX, web fallback, no hijacking risk |
| Mirror web URL structure in app | Consistent navigation, easier debugging |
| Handle missing content gracefully | Products get deleted, users change usernames |
| Track deep link attribution | Measure campaign effectiveness |
| Test on real devices | Simulators may not reflect real behavior |
| Provide web fallbacks | Not all users have your app installed |
| Use deferred deep links for campaigns | Don't lose users during app install |
Security Considerations
Deep links are user-controlled input—never forget that. This section covers the security implications of deep linking and how to protect your users from malicious links.
Deep Link Security
- • Validate all input — Deep link parameters are user-controlled
- • Don't auto-authenticate — Require user confirmation for sensitive actions
- • Sanitize URLs — Prevent open redirect vulnerabilities
- • Use Universal/App Links — Verified links prevent scheme hijacking
- • Expire sensitive links — Password reset links should be one-time use
// Validate deep link parameters
func handleDeepLink(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let productId = components.path.split(separator: "/").last,
productId.allSatisfy({ $0.isNumber }), // Validate format
productId.count < 20 // Prevent overflow
else {
// Invalid link - show error or go to home
navigateToHome()
return
}
// Safe to use productId
navigateToProduct(id: String(productId))
}The validation code shows essential checks: ensure the path component exists, validate the format (all digits in this case), and enforce length limits to prevent buffer overflows or database issues. Always fail safely by navigating to a known-good screen when validation fails.
Try the URL Builder
Use our URL Editor to build and test deep link URLs: