Kotlin Multiplatform (KMP) lets you share business logic between iOS and Android while keeping platform-specific UI and APIs. Deep link handling fits this model well: URL parsing and route matching are pure logic (shared), while receiving URLs and performing navigation are platform-specific. This article walks through building a shared deep link router in KMP.
For Android-specific deep link patterns, see Kotlin deep link handling: modern Android patterns. For the cross-platform overview, see cross-platform deep linking guide for 2026.
Architecture
KMP projects use expect/actual declarations for platform-specific code. For deep linking:
- Shared module (
commonMain): URL parsing, route matching, parameter extraction - Android (
androidMain): Intent handling, Activity navigation - iOS (
iosMain): Universal Link handling, UIKit/SwiftUI navigation
commonMain/
DeepLinkRouter.kt // Route matching logic
DeepLinkAction.kt // Action types
UrlParser.kt // URL parsing utilities
androidMain/
AndroidDeepLinkHandler.kt // Intent → action
AndroidNavigator.kt // Activity/Fragment navigation
iosMain/
IosDeepLinkHandler.kt // NSUserActivity → action
IosNavigator.kt // UIKit navigation
Shared Module: Route Matching
Route Definition
// commonMain/DeepLinkRouter.kt
data class RouteMatch(
val screen: String,
val params: Map<String, String>
)
data class RoutePattern(
val regex: Regex,
val screen: String,
val paramNames: List<String>
)
object DeepLinkRouter {
private val routes: List<RoutePattern> = listOf(
routePattern("/products/{productId}", "ProductDetail"),
routePattern("/offers/{offerId}", "OfferDetail"),
routePattern("/referral/{referrerId}", "Referral"),
routePattern("/categories/{categoryId}/products", "CategoryProducts"),
routePattern("/search", "Search")
)
fun match(urlString: String): RouteMatch? {
val parsed = parseUrl(urlString) ?: return null
val path = parsed.path
for (route in routes) {
val matchResult = route.regex.matchEntire(path) ?: continue
val params = mutableMapOf<String, String>()
route.paramNames.forEachIndexed { index, name ->
params[name] = matchResult.groupValues[index + 1]
}
// Add query parameters
parsed.queryParams.forEach { (key, value) ->
params[key] = value
}
return RouteMatch(screen = route.screen, params = params)
}
return null
}
private fun routePattern(pattern: String, screen: String): RoutePattern {
val paramNames = mutableListOf<String>()
val regexStr = pattern.replace(Regex("\\{([^}]+)\\}")) { matchResult ->
paramNames.add(matchResult.groupValues[1])
"([^/]+)"
}
return RoutePattern(
regex = Regex("^$regexStr$"),
screen = screen,
paramNames = paramNames
)
}
}
URL Parser
KMP does not have java.net.URL in common code. Write a simple parser or use a multiplatform library like Ktor's Url:
// commonMain/UrlParser.kt
data class ParsedUrl(
val scheme: String,
val host: String,
val path: String,
val queryParams: Map<String, String>
)
fun parseUrl(urlString: String): ParsedUrl? {
// Basic URL parsing for deep link purposes
val schemeEnd = urlString.indexOf("://")
if (schemeEnd == -1) return null
val scheme = urlString.substring(0, schemeEnd)
val rest = urlString.substring(schemeEnd + 3)
val pathStart = rest.indexOf('/')
if (pathStart == -1) return ParsedUrl(scheme, rest, "/", emptyMap())
val host = rest.substring(0, pathStart)
val pathAndQuery = rest.substring(pathStart)
val queryStart = pathAndQuery.indexOf('?')
val path: String
val queryParams: Map<String, String>
if (queryStart == -1) {
path = pathAndQuery.trimEnd('/')
queryParams = emptyMap()
} else {
path = pathAndQuery.substring(0, queryStart).trimEnd('/')
queryParams = parseQueryString(pathAndQuery.substring(queryStart + 1))
}
return ParsedUrl(scheme, host, path.ifEmpty { "/" }, queryParams)
}
private fun parseQueryString(query: String): Map<String, String> {
return query.split("&")
.filter { it.contains("=") }
.associate { param ->
val (key, value) = param.split("=", limit = 2)
key to value
}
}
For production use, consider the Ktor client URL utilities which handle edge cases like percent-encoding.
Shared Module: Action Types
Define typed actions so platform code does not need to interpret raw strings:
// commonMain/DeepLinkAction.kt
sealed class DeepLinkAction {
data class ViewProduct(
val productId: String,
val source: String? = null
) : DeepLinkAction()
data class ViewOffer(val offerId: String) : DeepLinkAction()
data class ApplyReferral(val referrerId: String) : DeepLinkAction()
data class Search(val query: String) : DeepLinkAction()
object OpenHome : DeepLinkAction()
}
fun RouteMatch.toAction(): DeepLinkAction {
return when (screen) {
"ProductDetail" -> DeepLinkAction.ViewProduct(
productId = params["productId"] ?: "",
source = params["ref"]
)
"OfferDetail" -> DeepLinkAction.ViewOffer(
offerId = params["offerId"] ?: ""
)
"Referral" -> DeepLinkAction.ApplyReferral(
referrerId = params["referrerId"] ?: ""
)
"Search" -> DeepLinkAction.Search(
query = params["q"] ?: ""
)
else -> DeepLinkAction.OpenHome
}
}
Android Implementation
Intent Handling
// androidMain/AndroidDeepLinkHandler.kt
class AndroidDeepLinkHandler(
private val navigator: AndroidNavigator
) {
fun handleIntent(intent: Intent) {
val uri = intent.data ?: return
handleUrl(uri.toString())
}
fun handleUrl(urlString: String) {
val route = DeepLinkRouter.match(urlString)
val action = route?.toAction() ?: DeepLinkAction.OpenHome
navigator.execute(action)
}
}
Activity Setup
// Android Activity
class MainActivity : ComponentActivity() {
private val deepLinkHandler by lazy {
AndroidDeepLinkHandler(AndroidNavigator(this))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Handle deep link from launch
deepLinkHandler.handleIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle deep link when app is already running
deepLinkHandler.handleIntent(intent)
}
}
Android Navigation
// androidMain/AndroidNavigator.kt
class AndroidNavigator(private val activity: ComponentActivity) {
fun execute(action: DeepLinkAction) {
when (action) {
is DeepLinkAction.ViewProduct -> {
// Using Jetpack Navigation or direct Intent
val intent = Intent(activity, ProductActivity::class.java).apply {
putExtra("productId", action.productId)
action.source?.let { putExtra("source", it) }
}
activity.startActivity(intent)
}
is DeepLinkAction.ViewOffer -> {
val intent = Intent(activity, OfferActivity::class.java).apply {
putExtra("offerId", action.offerId)
}
activity.startActivity(intent)
}
is DeepLinkAction.ApplyReferral -> {
// Store referral code, then navigate
ReferralStore.save(action.referrerId)
val intent = Intent(activity, HomeActivity::class.java)
activity.startActivity(intent)
}
is DeepLinkAction.Search -> {
val intent = Intent(activity, SearchActivity::class.java).apply {
putExtra("query", action.query)
}
activity.startActivity(intent)
}
is DeepLinkAction.OpenHome -> {
val intent = Intent(activity, HomeActivity::class.java)
activity.startActivity(intent)
}
}
}
}
AndroidManifest.xml
<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="/products" />
<data android:scheme="https"
android:host="yourdomain.com"
android:pathPrefix="/offers" />
<data android:scheme="https"
android:host="yourdomain.com"
android:pathPrefix="/referral" />
</intent-filter>
</activity>
iOS Implementation
For the iOS side of a KMP project, you have two options: write the handler in Kotlin (iosMain) and call it from Swift, or write it in Swift and call the shared Kotlin router.
Option 1: Kotlin iosMain with Swift Caller
// iosMain/IosDeepLinkHandler.kt
class IosDeepLinkHandler {
fun handleUrl(urlString: String): DeepLinkAction {
val route = DeepLinkRouter.match(urlString)
return route?.toAction() ?: DeepLinkAction.OpenHome
}
}
Call from Swift:
// AppDelegate.swift
import shared // KMP shared framework
class AppDelegate: UIResponder, UIApplicationDelegate {
let deepLinkHandler = IosDeepLinkHandler()
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard let url = userActivity.webPageUrl else { return false }
let action = deepLinkHandler.handleUrl(urlString: url.absoluteString)
navigate(action: action)
return true
}
private func navigate(action: DeepLinkAction) {
switch action {
case let product as DeepLinkAction.ViewProduct:
// Navigate to product screen
let vc = ProductViewController(productId: product.productId)
rootNavigationController?.pushViewController(vc, animated: true)
case let offer as DeepLinkAction.ViewOffer:
let vc = OfferViewController(offerId: offer.offerId)
rootNavigationController?.pushViewController(vc, animated: true)
case let referral as DeepLinkAction.ApplyReferral:
ReferralStore.shared.save(referrerId: referral.referrerId)
// Navigate to home
default:
// Navigate to home
break
}
}
}
Option 2: Swift-Only with Shared Router
If you prefer keeping the iOS navigation code entirely in Swift, just call the router:
import shared
func handleDeepLink(_ url: URL) {
guard let route = DeepLinkRouter.shared.match(urlString: url.absoluteString) else {
navigateToHome()
return
}
let action = DeepLinkActionKt.toAction(route)
// Handle action in Swift...
}
iOS Entitlements
Add applinks:yourdomain.com to Associated Domains in Xcode, same as any iOS Universal Links setup.
Testing the Shared Router
The shared module can be tested with standard Kotlin tests:
// commonTest/DeepLinkRouterTest.kt
class DeepLinkRouterTest {
@Test
fun matchesProductUrl() {
val result = DeepLinkRouter.match(
"https://yourdomain.com/products/abc123"
)
assertNotNull(result)
assertEquals("ProductDetail", result.screen)
assertEquals("abc123", result.params["productId"])
}
@Test
fun includesQueryParameters() {
val result = DeepLinkRouter.match(
"https://yourdomain.com/products/abc123?ref=email&campaign=summer"
)
assertNotNull(result)
assertEquals("email", result.params["ref"])
assertEquals("summer", result.params["campaign"])
}
@Test
fun returnsNullForUnknownPath() {
val result = DeepLinkRouter.match(
"https://yourdomain.com/unknown/path"
)
assertNull(result)
}
@Test
fun convertToViewProductAction() {
val route = DeepLinkRouter.match(
"https://yourdomain.com/products/abc123?ref=email"
)!!
val action = route.toAction()
assertTrue(action is DeepLinkAction.ViewProduct)
assertEquals("abc123", (action as DeepLinkAction.ViewProduct).productId)
assertEquals("email", action.source)
}
@Test
fun handlesTrailingSlash() {
val result = DeepLinkRouter.match(
"https://yourdomain.com/products/abc123/"
)
assertNotNull(result)
assertEquals("abc123", result.params["productId"])
}
}
These tests run on all KMP targets (JVM, Native, JS), verifying the routing logic works identically everywhere.
Gradle Configuration
Add the shared module dependency to both platform targets:
// shared/build.gradle.kts
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
// No external dependencies needed for basic routing
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
For production URL parsing, you can add Ktor:
commonMain.dependencies {
implementation("io.ktor:ktor-http:3.1.0")
}
Tolinku for KMP Projects
Tolinku hosts AASA and assetlinks.json verification files and provides deferred deep linking for users who install the app after tapping a link. Configure your iOS and Android app details in the Appspace settings, and Tolinku generates the verification files automatically. Routes with dynamic parameters work with any client-side routing implementation, including KMP shared routers.
For Android Kotlin patterns, see Kotlin deep link handling: modern Android patterns. For Xamarin/.NET MAUI, see Xamarin deep linking: cross-platform setup.
Get deep linking tips in your inbox
One email per week. No spam.