Skip to content
Tolinku
Tolinku
Sign In Start Free
Engineering · · 7 min read

Deep Linking with Kotlin Multiplatform (KMP)

By Tolinku Staff
|
Tolinku cross platform dashboard screenshot for engineering blog posts

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.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.