If you're building a mobile app that handles URLs, you need to understand deep linking. No way around it.
Key Takeaways
- URL Schemes: The Original Approach
- iOS Universal Links
- Android App Links
- The Edge Cases That Break Things
- Testing Deep Links
- A Note on React Native and Flutter
I've shipped deep linking implementations on four different production apps over the past few years, across both iOS and Android, and every single time I've run into problems I didn't anticipate. Not because the documentation is bad — it's actually decent in most cases — but because the gap between "this is how it's supposed to work" and "this is how it actually works on a user's device in the wild" is wider than you'd expect. Way wider.
This guide covers the technical implementation of deep linking on both platforms. I'll include code, configuration files, and the edge cases that have personally burned me. I'm going to be uneven in my platform coverage — heavier on iOS in some places, heavier on Android in others — because the pain points are distributed unevenly and I'd rather go deep on the stuff that actually trips people up than give you a perfectly symmetrical overview that's shallow everywhere.
URL Schemes: The Original Approach
Before we get into the modern stuff, let's talk about URL schemes because you'll still encounter them, and some apps still rely on them as a fallback.
A URL scheme is a custom protocol that your app registers with the operating system. Instead of https://, your app might register myapp://. When the OS encounters a URL with that scheme, it hands the URL to your app to handle. Our article on What Are Deep Links and Why They Matter explores this idea in more depth.
On iOS, you register a URL scheme in your app's Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
<key>CFBundleURLName</key>
<string>com.example.myapp</string>
</dict>
</array>
Then in your AppDelegate (or SceneDelegate if you're using scenes), you handle the incoming URL:
// AppDelegate.swift
func application(_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return false
}
// url looks like: myapp://product?id=12345
if components.host == "product",
let productId = components.queryItems?.first(where: { $0.name == "id" })?.value {
navigateToProduct(id: productId)
return true
}
return false
}
On Android, you add an intent filter to your activity 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>
And handle the incoming intent in your Activity:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleDeepLink(intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
intent?.let { handleDeepLink(it) }
}
private fun handleDeepLink(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme == "myapp" && uri.host == "product") {
val productId = uri.getQueryParameter("id")
productId?.let { navigateToProduct(it) }
}
}
Simple enough. The problem is everything I mentioned earlier: no verification, no fallback, potential scheme collisions. Don't build on URL schemes alone. They're fine as a secondary mechanism but shouldn't be your primary deep linking strategy in 2025.
iOS Universal Links

Universal Links are Apple's answer to the URL scheme problem. Instead of a custom protocol, you use regular https:// URLs. Your app tells iOS "I own example.com, and when someone taps a link to example.com/product/*, open my app instead of Safari." If the app isn't installed, the link just works as a normal web URL. Clean fallback.
Setting this up requires two things: server-side configuration and app-side configuration. You need both. Miss either one and nothing works, and the failure mode is silent. No error messages. The link just opens in Safari and you're left wondering what went wrong.
Server side: the apple-app-site-association file
You need to host a JSON file at https://yourdomain.com/.well-known/apple-app-site-association (no file extension, must be served with Content-Type: application/json). Here's what it looks like:
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.example.myapp"],
"components": [
{
"/": "/product/*",
"comment": "Match product pages"
},
{
"/": "/user/*",
"comment": "Match user profiles"
},
{
"/": "/invite/*",
"comment": "Match invite links"
}
]
}
]
}
}
A few things that have bitten me. The appIDs value must be your Apple Team ID followed by your bundle identifier, separated by a period. Not a dash. Not a slash. A period. I've seen this misconfigured more times than I can count. Also, the file must be served over HTTPS with a valid certificate. No redirects — Apple's CDN fetches this file directly, and if it encounters a redirect, it may fail silently. And the file must be accessible without authentication. If your server requires a login or returns a 401 for unauthenticated requests, this will not work.
The older format used a paths array instead of components. Both still work as of iOS 17, but Apple recommends the components format. If you're starting fresh, use components.
App side: the entitlement
In Xcode, go to your target's Signing & Capabilities tab, add the Associated Domains capability, and add an entry like applinks:yourdomain.com. If you need to support subdomains, you'll need separate entries for each, or you can use a wildcard: applinks:*.yourdomain.com. Though I've seen the wildcard behave inconsistently across iOS versions and I generally prefer explicit entries. For the full picture, read Universal Links vs App Links: iOS and Android Comparison.
Then handle the incoming Universal Link in your app:
// SceneDelegate.swift
func scene(_ scene: UIScene,
continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else {
return
}
// url is something like https://yourdomain.com/product/12345
let pathComponents = url.pathComponents
if pathComponents.count >= 3 && pathComponents[1] == "product" {
let productId = pathComponents[2]
navigateToProduct(id: productId)
}
}
// If using SwiftUI with App lifecycle:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
handleDeepLink(url)
}
}
}
}
A critical gotcha: onOpenURL handles both URL schemes and Universal Links in SwiftUI. But if you're using UIKit with SceneDelegate, URL schemes come through scene(_:openURLContexts:) while Universal Links come through scene(_:continue:). They're different delegate methods. I've seen developers implement one and forget the other, and then wonder why half their deep links don't work.
The caching problem
iOS downloads and caches the apple-app-site-association (AASA) file through Apple's CDN. When a user installs your app, iOS fetches the AASA file from Apple's CDN (not directly from your server). Apple's CDN refreshes this cache on its own schedule, and you can't force it. During development, this is infuriating. You update your AASA file, deploy it, and iOS still uses the old version because Apple's CDN hasn't refreshed yet. There's no reliable way to clear this cache. You can try the swcutil command-line tool on macOS, but it doesn't always help.
For development and testing, Apple introduced an alternate mode where you add applinks:yourdomain.com?mode=developer to your associated domains. In developer mode, iOS fetches the AASA file directly from your server instead of the CDN. But this only works on development-signed builds, not production. So you still can't fully test the CDN behavior before shipping.
Android App Links
Android's version of verified deep links is called App Links (previously "verified deep links" — they renamed it because, well, Android). The concept is similar to Universal Links: your app claims ownership of certain URL patterns on your domain, Android verifies that claim, and matching URLs open your app instead of the browser.
The manifest configuration is more involved than URL schemes:
<activity android:name=".MainActivity"
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="yourdomain.com"
android:pathPrefix="/product" />
</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="yourdomain.com"
android:pathPrefix="/user" />
</intent-filter>
</activity>
The android:autoVerify="true" attribute is what triggers the verification process. Without it, Android will show a disambiguation dialog asking the user which app they want to use to open the link, rather than opening your app automatically.
Server side: Digital Asset Links
Similar to Apple's AASA file, you host a JSON file at https://yourdomain.com/.well-known/assetlinks.json:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.myapp",
"sha256_cert_fingerprints": [
"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:12:34:56:78:90"
]
}
}
]
That sha256_cert_fingerprints field is the SHA-256 fingerprint of your app's signing certificate. And here's where Android gets tricky — if you use Google Play App Signing (which Google strongly encourages and is the default for new apps), the fingerprint you need is the one from Google's signing key, not your upload key. Your upload key is what you use to sign the APK or AAB before uploading to Google Play. Google then re-signs it with their key before distributing it to users. If you put your upload key's fingerprint in the assetlinks.json file, verification will fail for every user who installed your app from the Play Store. This mistake is really common and seriously frustrating to debug because everything works perfectly during local development (where you're using your own signing key) and breaks only in production.
To get the correct fingerprint, go to the Google Play Console, find your app, then Setup > App Integrity > App signing tab. The SHA-256 fingerprint is listed there. There's plenty more worth knowing, and Deep Linking for Better Mobile User Experience is a great place to start.
You can also include multiple fingerprints in the array for cases where you have both a debug and release signing certificate, or if you're migrating signing keys.
Handling the incoming link in code
// In your Activity or using Navigation Component
private fun handleDeepLink(intent: Intent) {
val uri = intent.data ?: return
when {
uri.pathSegments.firstOrNull() == "product" -> {
val productId = uri.pathSegments.getOrNull(1)
productId?.let { navigateToProduct(it) }
}
uri.pathSegments.firstOrNull() == "user" -> {
val username = uri.pathSegments.getOrNull(1)
username?.let { navigateToProfile(it) }
}
}
}
// If using Jetpack Navigation
val navController = findNavController(R.id.nav_host_fragment)
navController.handleDeepLink(intent)
Jetpack Navigation has built-in deep link support where you can define deep link patterns directly in your navigation graph XML, and the framework handles the routing for you. It's convenient but can be inflexible if your URL patterns are complex. I've used it on smaller projects and it works fine. On larger apps with intricate URL structures, I tend to write custom routing logic.
The Edge Cases That Break Things
Here's where I'm going to get into the stuff that documentation doesn't always cover. The failure modes. The things that work on your test device and break everywhere else.
Redirect chains kill deep linking. If your link goes through one or more redirects before arriving at the final URL, both Universal Links and App Links may fail to trigger. The OS checks the initial URL against the app's claimed patterns. If the initial URL is a tracking domain or a link shortener, it won't match, and the OS will open the browser. The browser then follows the redirect chain, but by that point, you're in the browser and the deep link opportunity is gone. This is a huge problem for marketing teams that use link tracking services. The solution is usually to set up deep link-compatible tracking that doesn't use HTTP redirects, or to use a deep linking provider like Branch that handles this correctly by using JavaScript-based redirects that preserve deep link behavior. It's annoying.
The iOS Safari long-press trap. If a user long-presses a Universal Link in Safari and selects "Open in Safari" (or "Open in new tab"), iOS remembers that preference for that domain. From that point forward, all Universal Links to that domain will open in Safari instead of the app. The user can reverse this by long-pressing again and selecting "Open in [App Name]," but most users don't know this. There's nothing you can do as a developer to override this preference. It's an iOS-level setting. I've had support tickets from users saying "the app stopped opening from links" and the fix was always this.
WebView inconsistencies. Universal Links generally don't work when tapped inside a WKWebView or SFSafariViewController that's embedded in another app. This means links tapped inside Facebook's in-app browser, Instagram's in-app browser, LinkedIn's in-app browser, Twitter/X's in-app browser, Gmail's in-app browser, and basically any other app that opens links internally will usually NOT trigger your Universal Link. The link loads as a web page. Some developers work around this with a "smart banner" or an intermediate landing page that detects the WebView environment and presents a button to open the native app. Something like:
<!-- Intermediate landing page -->
<a id="open-app" href="myapp://product/12345">Open in App</a>
<script>
// Try URL scheme as fallback in WebView contexts
setTimeout(function() {
// If we're still here after 500ms, the app didn't open
// Redirect to App Store or stay on web page
window.location.href = 'https://yourdomain.com/product/12345';
}, 500);
document.getElementById('open-app').click();
</script>
This is hacky. It doesn't work reliably. The timing-based approach (checking if the page is still visible after trying to open the app) is fragile and breaks on slower devices. But it's what a lot of developers resort to. I'm not proud of it, but I've shipped variations of this code.
Android's disambiguation dialog. Even with autoVerify set to true, Android may still show a "Choose an app" dialog in certain situations. If verification failed (maybe the assetlinks.json file was temporarily unreachable during app install), the user gets the dialog. If the user previously selected "Always open with Browser" for your domain, the dialog won't even appear — links just open in the browser. Users can reset this in Settings > Apps > [Your App] > Open by default, but nobody knows that's there. If you want to go further, Understanding Affiliate Links: A Beginner Guide has you covered.
On Android 12 and above, the behavior changed again. Google moved app link verification to happen at install time through the Play Store, and they added a new verification mechanism that's more reliable but also stricter. If your assetlinks.json file has any issues — wrong certificate fingerprint, server errors, redirects — verification fails and there's no retry. You have to push an app update or wait for the user to reinstall. You can check verification status via adb:
adb shell pm get-app-links com.example.myapp
Look for the verified state on your domain. If it says none or legacy_failure, something is wrong with your assetlinks.json configuration.
The state restoration problem. What happens when the user taps a deep link but your app is already running in the background on a different screen? On iOS, if your app is running, the Universal Link comes through scene(_:continue:) — but your app might be in a state where routing to the deep link destination is complicated. Maybe there's a modal presented. Maybe there's an incomplete form. Maybe there's an ongoing process. You need to decide whether to dismiss everything and route to the deep link, or queue the deep link and handle it later, or show a confirmation dialog. There's no right answer here — it depends on your app. But you need to think about it. I've seen apps crash because a deep link triggered navigation while an animation was in progress, or because the navigation stack was in an unexpected state.
// One approach: reset to root and then navigate
func handleDeepLink(_ url: URL) {
// Dismiss any presented view controllers
window?.rootViewController?.dismiss(animated: false) {
// Pop to root
if let nav = self.window?.rootViewController as? UINavigationController {
nav.popToRootViewController(animated: false)
}
// Now navigate to deep link destination
self.routeToDestination(from: url)
}
}
It's crude. But it's reliable. I'd rather have a slightly jarring navigation experience than a crash.
Testing Deep Links
Testing is where a lot of developers skip steps and then pay for it later.
For iOS Universal Links, the quickest test is to type your URL in the Notes app and then tap it. If the app opens, Universal Links are working. Don't test by typing the URL in Safari's address bar — that's not a tapped link, and Universal Links don't trigger from the address bar. You can also use xcrun simctl openurl booted "https://yourdomain.com/product/12345" in the simulator, though simulator behavior doesn't always match device behavior.
For Android, use adb:
adb shell am start -W -a android.intent.action.VIEW -d "https://yourdomain.com/product/12345" com.example.myapp
This forces the intent to your app, bypassing the disambiguation dialog. For a more realistic test, put the URL in an email or message and tap it on the device.
Apple provides a validation tool at https://search.developer.apple.com/appsearch-validation-tool/ that checks your AASA file. Google has a Digital Asset Links verification tool at https://developers.google.com/digital-asset-links/tools/generator. Use both. Run them after every change to your server configuration.
But passing these validation tools doesn't guarantee deep linking will work on actual devices. The tools verify that your configuration files are syntactically correct and accessible. They don't verify that iOS's CDN has the latest version of your file, or that the user's device has completed verification, or that the specific context where the link is being tapped supports deep linking. Manual testing on real devices across multiple scenarios is still necessary. Test from Messages. Test from Mail. Test from Safari. Test from Chrome on iOS. Test from within Facebook and Instagram and Twitter. Test with the app installed and not installed. Test on different OS versions. It's tedious. There's no shortcut. This connects to what we discuss in 301 vs 302 Redirects: Impact on Link Equity.
A Note on React Native and Flutter
If you're building with React Native, the Linking API handles both URL schemes and Universal Links/App Links. You still need all the native configuration described above — React Native doesn't abstract that away. But the JavaScript-side handling is straightforward:
import { Linking } from 'react-native';
// Handle link when app is already open
Linking.addEventListener('url', ({ url }) => {
handleDeepLink(url);
});
// Handle link that opened the app
const initialUrl = await Linking.getInitialURL();
if (initialUrl) {
handleDeepLink(initialUrl);
}
Flutter has the uni_links package (or app_links which is its successor) that does something similar. Again, native configuration is required. The package just provides a Dart API for receiving the incoming URLs.
Both frameworks have the same gotcha: if you're using a navigation library (React Navigation, go_router, etc.), make sure your deep link routing integrates with your navigation state correctly. I've seen apps where tapping a deep link pushed a new screen on top of whatever was already there, creating weird back-stack behavior. The user taps back and ends up on a screen that makes no sense in context.
The Reality of Maintaining Deep Links
Deep linking isn't a set-it-and-forget-it feature. URL patterns change. App screens get renamed or removed. New features need new deep link routes. The AASA and assetlinks.json files need to be updated and redeployed whenever you add new patterns. And those updates need to propagate through Apple's CDN and Android's verification system, which don't happen instantly.
I've worked on a project where the team added deep linking support during initial development and then never touched it again. Eighteen months later, half the deep links were broken because the app's navigation structure had changed and nobody updated the deep link routing logic. URLs that used to map to specific screens now pointed to screens that no longer existed, causing crashes or blank screens. The AASA file still referenced URL patterns for features that had been sunset.
My recommendation: treat deep link routes like API endpoints. Document them. Version them if necessary. Include them in your test suite. When you remove or rename a screen, update the deep link routing. When you add a new screen that should be deep-linkable, add the URL pattern to your server configuration and your routing logic at the same time. Don't leave it for later. "Later" means "never" in most codebases.
This stuff breaks all the time and that's normal. The platforms change behavior between OS versions. WebView implementations evolve. New apps introduce new in-app browsers with new quirks. Apple and Google modify their verification systems. Privacy regulations alter what's possible for attribution and deferred deep linking. The best you can do is implement it correctly according to current specifications, test thoroughly across realistic scenarios, monitor for failures in production (track how often deep links successfully route to the intended screen vs. falling back to a generic screen), and accept that you'll be revisiting this code periodically for as long as your app exists. That's the deal.
It's not glamorous engineering work, and it's rarely the thing that gets highlighted in sprint demos. But when it works, users don't even notice it — they just tap a link and end up where they expected. And when it doesn't work, they absolutely notice. Getting users to the right place with minimal friction is one of those invisible things that defines whether an app feels polished or janky. Deep linking is plumbing. Nobody celebrates working plumbing. But everyone notices when it's broken.
Comments (0)
No comments yet. Be the first to share your thoughts!
Leave a Comment