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

Kotlin Deep Link Handling: Modern Android Patterns

By Tolinku Staff
|
Tolinku app links dashboard screenshot for android blog posts

Deep link handling in Android has evolved considerably as Kotlin became the standard language and Jetpack components matured. Code that worked in 2019 with Java and manual intent parsing is now better expressed with sealed classes, the Navigation component, and coroutines. This article covers the patterns that work well in 2026 for production Android apps.

The examples assume you have App Links verification set up (see our Android App Links developer documentation if not). The focus here is on what happens after Android routes a URL to your app.

Modeling Routes with Sealed Classes

The first improvement over ad-hoc string parsing is representing your routes as a type hierarchy. Sealed classes give you exhaustive pattern matching and make the compiler your ally when you add new routes.

Define a sealed class for all deep link destinations in your app:

sealed class DeepLinkRoute {
    data class ProductDetail(val productId: String) : DeepLinkRoute()
    data class OrderConfirmation(val orderId: String) : DeepLinkRoute()
    data class UserProfile(val userId: String) : DeepLinkRoute()
    data class Checkout(val cartToken: String, val promo: String?) : DeepLinkRoute()
    object Home : DeepLinkRoute()
    data class Unknown(val uri: Uri) : DeepLinkRoute()
}

Then write a parser that converts a Uri to one of these types:

object DeepLinkParser {

    fun parse(uri: Uri): DeepLinkRoute {
        if (uri.scheme != "https") return DeepLinkRoute.Unknown(uri)
        if (uri.host != "yourdomain.com") return DeepLinkRoute.Unknown(uri)

        val segments = uri.pathSegments

        return when {
            segments.size == 2 && segments[0] == "products" -> {
                DeepLinkRoute.ProductDetail(productId = segments[1])
            }
            segments.size == 2 && segments[0] == "orders" -> {
                DeepLinkRoute.OrderConfirmation(orderId = segments[1])
            }
            segments.size == 2 && segments[0] == "users" -> {
                DeepLinkRoute.UserProfile(userId = segments[1])
            }
            segments.size == 1 && segments[0] == "checkout" -> {
                val token = uri.getQueryParameter("token") ?: return DeepLinkRoute.Unknown(uri)
                val promo = uri.getQueryParameter("promo")
                DeepLinkRoute.Checkout(cartToken = token, promo = promo)
            }
            segments.isEmpty() -> DeepLinkRoute.Home
            else -> DeepLinkRoute.Unknown(uri)
        }
    }
}

The Unknown case is important. Do not crash or silently fail on unrecognized URLs. Log them, handle them gracefully, and route the user somewhere sensible (usually Home or an error screen).

For apps with a single Activity, the entry point for deep links is onCreate and onNewIntent. Both must be handled because:

  • onCreate fires when the app is launched fresh from a link click
  • onNewIntent fires when the app is already running and a new link is clicked (with launchMode="singleTask" or singleTop)
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        intent?.data?.let { uri ->
            handleDeepLink(uri)
        }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        intent.data?.let { uri ->
            handleDeepLink(uri)
        }
    }

    private fun handleDeepLink(uri: Uri) {
        val route = DeepLinkParser.parse(uri)
        navigateTo(route)
    }

    private fun navigateTo(route: DeepLinkRoute) {
        when (route) {
            is DeepLinkRoute.ProductDetail ->
                navController.navigate(
                    ProductDetailFragmentDirections.actionToProductDetail(route.productId)
                )
            is DeepLinkRoute.OrderConfirmation ->
                navController.navigate(
                    OrderFragmentDirections.actionToOrderConfirmation(route.orderId)
                )
            is DeepLinkRoute.UserProfile ->
                navController.navigate(
                    ProfileFragmentDirections.actionToUserProfile(route.userId)
                )
            is DeepLinkRoute.Checkout ->
                navController.navigate(
                    CheckoutFragmentDirections.actionToCheckout(
                        route.cartToken,
                        route.promo
                    )
                )
            is DeepLinkRoute.Home ->
                navController.navigate(R.id.homeFragment)
            is DeepLinkRoute.Unknown -> {
                Log.w("DeepLink", "Unrecognized URI: ${route.uri}")
                navController.navigate(R.id.homeFragment)
            }
        }
    }
}

The when expression on a sealed class is exhaustive: the compiler warns if you miss a branch. As you add new route types to the sealed class, you will get a compile-time reminder to add handling everywhere you use when.

Some deep links require async operations before navigation. For example, a shared content link might need to fetch the content first to determine if the user has access, or a referral link might need to record attribution before navigating.

Use a ViewModel to encapsulate this logic:

class MainViewModel : ViewModel() {

    private val _navigationEvent = MutableSharedFlow<DeepLinkRoute>()
    val navigationEvent: SharedFlow<DeepLinkRoute> = _navigationEvent.asSharedFlow()

    fun processDeepLink(uri: Uri) {
        viewModelScope.launch {
            val route = DeepLinkParser.parse(uri)
            val resolvedRoute = resolveRoute(route)
            _navigationEvent.emit(resolvedRoute)
        }
    }

    private suspend fun resolveRoute(route: DeepLinkRoute): DeepLinkRoute {
        return when (route) {
            is DeepLinkRoute.ProductDetail -> {
                // Check if product exists and user has access
                val product = productRepository.getProduct(route.productId)
                if (product == null) DeepLinkRoute.Home else route
            }
            is DeepLinkRoute.Checkout -> {
                // Validate cart token before navigating
                val isValid = cartRepository.validateToken(route.cartToken)
                if (!isValid) DeepLinkRoute.Home else route
            }
            else -> route
        }
    }
}

In your Activity, collect the navigation events:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    lifecycleScope.launch {
        viewModel.navigationEvent
            .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
            .collect { route ->
                navigateTo(route)
            }
    }

    intent?.data?.let { uri ->
        viewModel.processDeepLink(uri)
    }
}

Using SharedFlow rather than LiveData for navigation events avoids the "re-navigate on rotation" problem. SharedFlow with replay = 0 (the default) only emits to active collectors and does not replay the last event to new subscribers.

The flowWithLifecycle operator ensures you do not process navigation events when the Activity is in the background, which prevents navigation calls on a stopped Activity.

If your deep link routing needs to happen at the Fragment level (for example, in a multi-Activity app, or when a specific Fragment needs to handle links directly), the same principles apply but with Fragment lifecycle awareness.

class ProductFragment : Fragment() {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Handle deep link argument passed via Navigation component
        val productId = arguments?.getString("productId")
            ?: requireActivity().intent?.data?.lastPathSegment

        productId?.let {
            viewModel.loadProduct(it)
        }
    }
}

When using the Navigation component, prefer passing data through Navigation's argument system rather than reading the raw Intent URI in Fragments. This keeps Fragments unaware of the URL structure and makes them testable without an Intent. For Navigation component deep link integration, see Jetpack Navigation Deep Links.

Back Stack Considerations

When a user arrives via a deep link, the back stack matters. If the user presses Back after arriving at a product detail screen via a deep link, they should not land in an empty app or exit entirely. They should arrive at a logical parent screen.

For Navigation component users, define the back stack in the nav graph:

<fragment
    android:id="@+id/productDetailFragment"
    android:name="com.example.ProductDetailFragment">
    <deepLink
        android:id="@+id/deepLink"
        app:uri="https://yourdomain.com/products/{productId}" />
    <argument
        android:name="productId"
        app:argType="string" />
</fragment>

When the Navigation component handles the deep link, it constructs the back stack defined by your nav graph. The user can press Back and navigate to the home destination rather than exiting.

For deep links that should not add to the back stack (such as password reset links where you want the user to land on a clean state), use Navigation's popUpTo and inclusive flags in your action.

Your app may still handle yourapp:// custom URI schemes alongside HTTPS App Links. The handling code is the same: both deliver a Uri to your Activity via intent.data. The parser can handle both:

object DeepLinkParser {

    fun parse(uri: Uri): DeepLinkRoute {
        return when (uri.scheme) {
            "https" -> parseHttps(uri)
            "yourapp" -> parseCustomScheme(uri)
            else -> DeepLinkRoute.Unknown(uri)
        }
    }

    private fun parseHttps(uri: Uri): DeepLinkRoute {
        // ... HTTPS parsing
    }

    private fun parseCustomScheme(uri: Uri): DeepLinkRoute {
        // yourapp://products/123 -> ProductDetail("123")
        val segments = uri.pathSegments
        return when {
            segments.size == 2 && segments[0] == "products" ->
                DeepLinkRoute.ProductDetail(segments[1])
            else -> DeepLinkRoute.Unknown(uri)
        }
    }
}

For a production app, App Links (HTTPS) should be your primary mechanism, with custom URI schemes as a fallback. App Links do not require the user to have seen a "chooser" dialog and are not spoofable by other apps, unlike custom URI schemes.

Testing deep link code is straightforward because it is just function calls with a Uri input and navigation output. Unit test your parser:

@Test
fun `parses product detail URL correctly`() {
    val uri = Uri.parse("https://yourdomain.com/products/abc123")
    val route = DeepLinkParser.parse(uri)
    assertThat(route).isEqualTo(DeepLinkRoute.ProductDetail("abc123"))
}

@Test
fun `returns Unknown for unrecognized paths`() {
    val uri = Uri.parse("https://yourdomain.com/unknown/path/here")
    val route = DeepLinkParser.parse(uri)
    assertThat(route).isInstanceOf(DeepLinkRoute.Unknown::class.java)
}

For integration testing, use ADB to fire the intent:

adb shell am start \
  -W -a android.intent.action.VIEW \
  -d "https://yourdomain.com/products/abc123" \
  com.yourcompany.app

This tests the full path from intent receipt through navigation without needing a real link click.

For the SDK integration to handle deferred deep links and attribution alongside this routing, see the Android SDK documentation.

Error Handling

Deep links arrive from outside your app and cannot be trusted to be well-formed. Always handle:

  • Missing required path segments
  • Invalid formats (non-numeric IDs when you expect a number)
  • Extra or unexpected query parameters
  • Fragments in the URI (#section)
  • Percent-encoded characters

Kotlin's runCatching can wrap parsing operations that might throw:

fun parse(uri: Uri): DeepLinkRoute {
    return runCatching {
        parseInternal(uri)
    }.getOrElse { exception ->
        Log.e("DeepLink", "Failed to parse URI: $uri", exception)
        DeepLinkRoute.Unknown(uri)
    }
}

Never let a malformed deep link crash your app. The failure should be graceful, logged for debugging, and transparent to the user.

Summary

Modern Kotlin deep link handling comes down to a few patterns working together: sealed classes model your routes as a type hierarchy, a dedicated parser converts URIs to routes with proper error handling, a ViewModel handles async resolution and navigation events, and the Navigation component manages the back stack.

The result is deep link code that is testable, maintainable, and easy to extend. Adding a new route type means adding to the sealed class and updating the parser; the compiler tells you everywhere else that needs updating.

For the full context on setting up App Links verification before this code has any effect, see the complete Android App Links guide and the App Links concepts documentation. For intent filter configuration, see Android Intent Filters for Deep Links.

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.