
During a race, the engineer keys the radio and says two words: “Box, box.” The driver doesn’t drive a full extra lap to think about it. The next time the pit entry appears, they peel off the racing line and go straight to their box. No detour through the rest of the circuit. One instruction, one destination.
A deep link is that radio call for your app. Something outside the app, like a notification, an email, or a shared URL, says “go here”, and the user lands on a specific screen instead of the home screen. They skip the flow entirely.
I ran into the messy version of this while wiring up push notifications for a KMP app that had Android and iOS UI sharing a single navigation layer. The notification payload carried an order ID. Tapping it was supposed to open the order detail screen. On Android I had intent filters. On iOS I had Universal Links and a URL scheme. Two platform mechanisms, two ways of receiving the raw URL, and one navigation graph in commonMain that had no idea any of this existed. The question was where to draw the line between “platform receives the URL” and “shared code decides what to do with it.”
My answer: the platforms receive a string, and that’s all they do. Everything after that lives in shared code.
The two halves of a deep link
A deep link has a receiving half and a routing half, and they belong in different places.
The receiving half is platform work. Android and iOS each have their own way of registering which URLs your app responds to and delivering them when the user taps one. There’s no shared abstraction that makes this go away, and trying to invent one usually costs more than it saves.
The routing half is pure logic. You take a URL like myapp://order/4821, parse out the path and parameters, and turn it into something your navigation layer understands. That’s a string-in, destination-out function. No platform APIs, no UI framework. It runs identically on JVM, Android, and iOS, which means it belongs in commonMain and it means you can unit test it without a device.
So the shape of the whole thing is: each platform catches the URL string and hands it to shared code. Shared code parses it into a typed destination. The navigation layer reacts. The platform glue stays thin on purpose.
The shared router
Start with the destination. I model the screens a link can open as a sealed interface in commonMain:
sealed interface DeepLinkDestination {
data object Home : DeepLinkDestination
data class OrderDetail(val orderId: String) : DeepLinkDestination
data class Profile(val userId: String) : DeepLinkDestination
data object Unknown : DeepLinkDestination
}
Then a parser that turns a raw URL into one of those. I use Ktor’s Url for this because it’s already multiplatform and it handles the path and query parsing without me writing a regex I’ll regret:
import io.ktor.http.Url
object DeepLinkParser {
fun parse(raw: String): DeepLinkDestination {
val url = runCatching { Url(raw) }.getOrNull()
?: return DeepLinkDestination.Unknown
// https://kmpbits.com/order/4821 -> segments = ["order", "4821"]
// myapp://order/4821 -> "order" is parsed as the host, segments = ["4821"]
val segments = when (url.protocol.name) {
"http", "https" -> url.segments
else -> listOf(url.host) + url.segments
}.filter { it.isNotEmpty() }
return when (segments.firstOrNull()) {
"order" -> segments.getOrNull(1)
?.let { DeepLinkDestination.OrderDetail(it) }
?: DeepLinkDestination.Unknown
"profile" -> segments.getOrNull(1)
?.let { DeepLinkDestination.Profile(it) }
?: DeepLinkDestination.Unknown
null -> DeepLinkDestination.Home
else -> DeepLinkDestination.Unknown
}
}
}
The when (url.protocol.name) is there because of a trap: with a custom scheme, myapp://order/4821 parses order as the host, not as the first path segment. Without that branch, the custom-scheme link would fall straight into Unknown while the HTTPS version worked fine. Both formats go into a unit test:
class DeepLinkParserTest {
@Test
fun customSchemeAndHttpsResolveToTheSameDestination() {
val expected = DeepLinkDestination.OrderDetail("4821")
assertEquals(expected, DeepLinkParser.parse("myapp://order/4821"))
assertEquals(expected, DeepLinkParser.parse("https://kmpbits.com/order/4821"))
}
}
The Unknown case matters more than it looks. A deep link is an open door, and you don’t control who knocks. A malformed URL, a path you renamed three releases ago, a parameter someone hand-edited: all of it arrives eventually. If your parser can’t produce a sane destination, it should say so explicitly instead of crashing or guessing. I send Unknown to a fallback, usually Home, sometimes a small “this link isn’t valid” screen.
Now the part that ties parsing to navigation. I keep a single entry point that the platforms call:
class DeepLinkHandler(
private val navigator: AppNavigator,
) {
fun handle(raw: String) {
when (val dest = DeepLinkParser.parse(raw)) {
is DeepLinkDestination.OrderDetail -> navigator.toOrderDetail(dest.orderId)
is DeepLinkDestination.Profile -> navigator.toProfile(dest.userId)
DeepLinkDestination.Home -> navigator.toHome()
DeepLinkDestination.Unknown -> navigator.toHome()
}
}
}
AppNavigator is whatever navigation abstraction you already have. If you’re on Navigation 3 it wraps your back stack; if you’re on a custom solution it wraps that. The point is DeepLinkHandler doesn’t care. It speaks destinations, and the navigator speaks screens.
Android: catching the URL
On Android, you declare the URLs your app answers to in the manifest with an intent filter. Here’s a custom scheme plus an HTTPS App Link on the same activity:
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<!-- Custom scheme: myapp://order/4821 -->
<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>
<!-- App Link: https://kmpbits.com/order/4821 -->
<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="kmpbits.com" />
</intent-filter>
</activity>
launchMode="singleTask" is the detail people miss. Without it, a deep link tapped while your app is already running can spawn a second instance of the activity, and now you have two back stacks fighting each other. With singleTask, the existing activity gets the new intent through onNewIntent instead.
Which is why you read the intent in two places:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Cold start: app launched by the link.
// Only on a fresh start: after a rotation the same intent is
// still attached and would navigate a second time.
if (savedInstanceState == null) {
intent?.data?.toString()?.let { deepLinkHandler.handle(it) }
}
setContent { App() }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Warm: app already running, new link arrives
intent.data?.toString()?.let { deepLinkHandler.handle(it) }
}
}
onCreate covers the cold start, where tapping the link is what launched the process. onNewIntent covers the case where the app was already alive. Handle only one and half your links will silently do nothing, and it’ll be the half you don’t test on your own phone.
For App Links to open without the Android chooser dialog, you also need an assetlinks.json file served at https://kmpbits.com/.well-known/assetlinks.json. autoVerify="true" tells Android to fetch it and check that your app’s signing certificate is listed:
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.kmpbits.app",
"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"
]
}
}
]
The package name and fingerprint above are placeholders. Replace them with your own, and make sure they match exactly, because a single wrong character means verification silently fails and Android falls back to the chooser dialog. The fingerprint is the SHA-256 of the certificate that signs the build users actually install. If you use Play App Signing, that’s the app signing key shown in the Play Console under App integrity, not your upload key. For local builds, ./gradlew signingReport prints the debug one, and you can list several fingerprints in the array.
iOS: the same string, a different door
iOS has two mechanisms. URL schemes (myapp://) are the old, simple one. Universal Links (https://) are the modern one and the only kind Apple really wants you to use for web-mapped content.
For a URL scheme, you register it in Info.plist under CFBundleURLTypes. For Universal Links, you add the Associated Domains capability with applinks:kmpbits.com and serve an apple-app-site-association file (no extension, application/json, no redirects) at https://kmpbits.com/.well-known/apple-app-site-association. Its paths must match the routes your parser understands:
{
"applinks": {
"details": [
{
"appIDs": ["ABCDE12345.com.kmpbits.app"],
"components": [
{ "/": "/order/*" },
{ "/": "/profile/*" }
]
}
]
}
}
The app ID is a placeholder too. ABCDE12345 is your Apple Team ID and com.kmpbits.app your bundle identifier, and both must match your real values exactly or iOS won’t associate the domain with your app. Any path not listed here opens in Safari instead of your app, so when you add a new DeepLinkDestination, add its pattern here too.
The receiving code depends on your app’s entry point. If you’re using KMP with a SwiftUI shell, the cleanest hook is the SwiftUI lifecycle modifier:
import SwiftUI
import shared // your KMP framework
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ComposeView()
// Custom scheme: myapp://order/4821
.onOpenURL { url in
DeepLinkBridge.shared.handle(raw: url.absoluteString)
}
// Universal Link: https://kmpbits.com/order/4821
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL {
DeepLinkBridge.shared.handle(raw: url.absoluteString)
}
}
}
}
}
DeepLinkBridge is a small object exposed from commonMain that holds the same DeepLinkHandler instance your Compose UI uses. The Swift side does exactly what the Android side does: grab the URL as a string, pass it across. It makes no routing decisions. Notice the symmetry. Both platforms have a cold path and a warm path, both end at the same handle(raw:) call, and neither one knows what an order ID is.
Wiring it into navigation
There’s a timing problem that’s easy to miss. A cold-start deep link arrives before your navigation graph exists. If handle fires in onCreate but your back stack isn’t built until the first composition, the navigation call lands in the void.
I solve it with a pending slot. The handler stores the destination if navigation isn’t ready, and the navigation layer drains it once it is:
class DeepLinkHandler(
private val navigator: AppNavigator,
) {
private var pending: DeepLinkDestination? = null
fun handle(raw: String) {
val dest = DeepLinkParser.parse(raw)
if (navigator.isReady) navigate(dest) else pending = dest
}
fun onNavigatorReady() {
pending?.let { navigate(it) }
pending = null
}
private fun navigate(dest: DeepLinkDestination) {
when (dest) {
is DeepLinkDestination.OrderDetail -> navigator.toOrderDetail(dest.orderId)
is DeepLinkDestination.Profile -> navigator.toProfile(dest.userId)
else -> navigator.toHome()
}
}
}
You call onNavigatorReady() from a LaunchedEffect once your root composable is set up. It’s a small amount of bookkeeping, and it’s the difference between deep links that work reliably and deep links that work only when the app was already open.
Testing without a browser
You don’t need a web page with a link to try any of this. Both platforms can fire a URL at your app from the command line, which is also the quickest way to cover the cold and warm paths.
On Android, with a device or emulator connected:
adb shell am start -a android.intent.action.VIEW \
-d "myapp://order/4821" com.kmpbits.app
On the iOS simulator:
xcrun simctl openurl booted "myapp://order/4821"
For the cold path, kill the app first (adb shell am force-stop com.kmpbits.app on Android, or swipe it away on the emulator), then run the command. For the warm path, leave the app open or backgrounded and run it again. The custom scheme works straight away with these commands. The https:// versions only open your app once the assetlinks.json and apple-app-site-association files are live on your domain and match your signing setup, which is why I’d test the routing with myapp:// first and the verified links last.
Try a few broken inputs too: myapp://order/, myapp://nope, and an empty path. They should all land on your Unknown fallback instead of crashing.
Gotchas I’d flag before you ship
Cold start versus warm start. Said it above, saying it again because it’s the number one source of “works on my machine” deep link bugs. Test both states on both platforms. Force-quit the app, tap the link, then repeat with the app backgrounded.
The back stack after a deep link. When a user lands on order detail from a notification, what happens when they hit back? On a normal flow they’d go to the order list. On a deep link there is no list behind it. Decide deliberately: either synthesize a sensible back stack (Home then Order, so back goes to Home) or let back exit. Don’t leave it to chance, because the default is usually wrong.
Untrusted parameters. That order ID came from outside your app. Validate it before you fetch anything with it.
Verification files are deployment, not code. assetlinks.json and apple-app-site-association live on your web server, not in your app bundle. If links open a browser instead of your app, the file is almost always missing, malformed, or cached. Check those before you suspect your code.
The shared core, the thin edges
The thing I like about this setup is how little of it is platform-specific. The parser, the destination model, the handler, the pending logic: all of it sits in commonMain and runs the same everywhere. The Android and iOS code does one job each: catch a string, pass it on. That’s the whole point of KMP applied to a problem that, on the surface, looks deeply platform-bound.
The radio call is the same on every circuit. “Box, box.” Where the box actually is changes per track, and that’s fine, because by the time the driver gets there, they already know exactly what to do.
If you’ve got a navigation setup you want to bolt this onto, the parser is the piece to write first. Get string-to-destination right and unit tested, and the platform glue is almost boring after that.
Comments
Loading comments...