---
title: "Type-Safe Deep Links in Jetpack Navigation 3: UriDeepLinkMatcher, DeepLinkSerializer, and Synthetic Back Stacks"
description: "How androidx.navigation3:navigation3-runtime 1.2+ maps DeepLinkRequest instances to type-safe NavKey back stacks using UriDeepLinkMatcher, DeepLinkSerializer, and lazy withBackStack builders."
pubDatetime: 2026-11-12
author: "Arda Kazancı"
canonicalUrl: "https://ardakazanci.com/posts/type-safe-deep-links-jetpack-navigation-3/"
tags: ["android", "kotlin", "compose", "architecture", "androidx"]
---
When Jetpack Navigation 3 shipped its 1.0 release in November 2025, deep linking lived outside the library as copy-paste helpers in the [nav3-recipes](https://github.com/android/nav3-recipes) repository. Starting in `androidx.navigation3:navigation3-runtime:1.2.0` (currently `1.2.1`), those helpers graduated into first-party APIs built around `DeepLinkRequest`, `UriDeepLinkMatcher`, `DeepLinkSerializer`, and `withBackStack`.

In their Android Dev Summit session, [Building type-safe deep links with Jetpack Navigation 3](https://developer.android.com/events/dev-summit/talks/building-type-safe-deep-links-with-jetpack-navigation3), Ian Lake and Clara Fok walked through how these APIs fit Navigation 3's core rule: you own the back stack. Because your app manages a `List<NavKey>` (or `NavBackStack<NavKey>`) directly, the [Navigation 3 deep links API](https://developer.android.com/guide/navigation/navigation-3/deep-links) has one job: turn an incoming deep link into the initial list of `NavKey` instances that your `NavDisplay` renders.

## Matching URIs with DeepLinkRequest and UriDeepLinkMatcher

To use the deep link APIs, add `navigation3-runtime` `1.2.1` (or `1.2.0+`) alongside the Kotlin Serialization compiler plugin:

```toml title="gradle/libs.versions.toml"
[versions]
navigation3 = "1.2.1"
kotlinxSerialization = "1.9.0"

[libraries]
androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" }
kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" }
```

Android still requires `<intent-filter>` declarations in `AndroidManifest.xml` so the system knows which links open your `Activity`. Once the `Activity` starts, Navigation 3 wraps the incoming input in a Kotlin Multiplatform `DeepLinkRequest`. On Android, `DeepLinkRequest(intent)` reads `intent.data`, the action, the MIME type, and any `Intent` extras (stored as a `SavedState` under `DeepLinkRequest.IntentExtrasKey`). In shared KMP code or unit tests, you construct `DeepLinkRequest` directly from a `DeepLinkUri` or `String` without mocking `android.content.Intent`.

To map a `DeepLinkRequest` to a destination key, pass a pattern `DeepLinkUri` and the target key's `KSerializer` to `UriDeepLinkMatcher`:

```kotlin title="SessionDeepLinkMatcher.kt"
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.deeplink.DeepLinkRequest
import androidx.navigation3.runtime.deeplink.DeepLinkUri
import androidx.navigation3.runtime.deeplink.UriDeepLinkMatcher
import androidx.navigation3.runtime.deeplink.UriMatchResult
import kotlinx.serialization.Serializable
import kotlinx.serialization.serializer

@Serializable
data object Home : NavKey

@Serializable
data class Session(val id: String) : NavKey

// Omitting the scheme matches both http and https
val sessionMatcher = UriDeepLinkMatcher(
    uriPattern = DeepLinkUri("ads.android.com/session/{id}"),
    serializer = serializer<Session>()
)

val request = DeepLinkRequest(
    uri = DeepLinkUri("https://ads.android.com/session/abcd")
)

val matchResult: UriMatchResult<Session>? = sessionMatcher.match(request)
val backStack: List<NavKey> = listOf(matchResult?.key ?: Home)
```

Curly braces in `ads.android.com/session/{id}` mark placeholders extracted from path segments, query parameters, or URI fragments. `UriDeepLinkMatcher` feeds those extracted strings into a custom `kotlinx.serialization` decoder, so placeholder names in the URI pattern must match the property names on `Session` (or their `@SerialName` overrides).

Scheme rules in `UriDeepLinkMatcher` are asymmetric by design. Leaving out the scheme (`ads.android.com/...`) or specifying `http://` matches both `http` and `https` requests. Specifying `https://` restricts matching to `https` requests only. Host authorities are matched case-insensitively without wildcards, so `ads.android.com` and `www.ads.android.com` require separate matchers unless you normalize the URI beforehand.

## Resolving overlapping patterns and multi-module DI

Real apps often support multiple URLs for the same screen, such as a legacy newsletter link alongside a current web URL. Each pattern gets its own `UriDeepLinkMatcher` instance in a list.

Sometimes two valid patterns overlap. Suppose you keep `ads.android.com/session/{id}` for regular talks and add a dedicated `Keynote` screen at `ads.android.com/session/keynote`:

```kotlin title="OverlappingMatchers.kt"
@Serializable
data object Keynote : NavKey

val matchers = listOf(
    UriDeepLinkMatcher(
        uriPattern = DeepLinkUri("ads.android.com/session/{id}"),
        serializer = serializer<Session>()
    ),
    UriDeepLinkMatcher(
        uriPattern = DeepLinkUri("ads.android.com/session/keynote"),
        serializer = serializer<Keynote>()
    )
)

val request = DeepLinkRequest(uri = DeepLinkUri("https://ads.android.com/session/keynote"))

val bestKey: NavKey = matchers
    .mapNotNull { matcher -> matcher.match(request) }
    .maxOrNull()
    ?.key ?: Home
```

An incoming request for `/session/keynote` matches both entries: `Session(id = "keynote")` and `Keynote`. Because `DeepLinkMatcher.MatchResult` implements `Comparable`, calling `maxOrNull()` ranks all non-null matches and picks `Keynote`. `UriMatchResult` scores exact literal paths above placeholder or wildcard segments, then breaks ties using path argument count and total argument count.

In a multi-module project where navigation keys live in `:feature:api` and entries live in `:feature:impl`, hardcoding a central list in `:app` creates unnecessary coupling. Each feature module can contribute its matchers into a DI multibinding set instead:

```kotlin title="FeatureDeepLinkModule.kt"
@Module
@InstallIn(ActivityRetainedComponent::class)
object SessionDeepLinkModule {
    @IntoSet
    @Provides
    fun provideSessionMatcher(): DeepLinkMatcher<*, *> =
        UriDeepLinkMatcher(
            uriPattern = DeepLinkUri("ads.android.com/session/{id}"),
            serializer = serializer<Session>()
        )
}

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @Inject
    lateinit var matchers: Set<@JvmSuppressWildcards DeepLinkMatcher<*, *>>
}
```

The Navigation team uses this multibinding setup across their Hilt, Metro, and Koin recipes, as well as in Now in Android.

## Parsing non-string types and custom DeepLinkSerializer

Because `UriDeepLinkMatcher` delegates instantiation to `kotlinx.serialization`, primitive types (`Int`, `Long`, `Float`, `Double`, `Boolean`), enums, repeated query collections (`?tag=compose&tag=nav3` into `List<String>`), and flattened nested `@Serializable` classes work without extra glue code:

```kotlin title="TypedSessionKey.kt"
// Pattern: "ads.android.com/session/{id}#referrer={referrer}"
@Serializable
data class Session(
    val id: Int,
    val referrer: String? = null
) : NavKey
```

Type mismatches do not crash your `Activity`. If a user opens `ads.android.com/session/not-an-int`, decoding fails internally and `matcher.match(request)` returns `null` so the remaining matchers can run. Optional query or fragment arguments map to properties that declare Kotlin default values (`val referrer: String? = null`). Keep in mind that `UriDeepLinkMatcher` throws an exception if a key class declares unsupported shapes like `Map<String, String>` or nested `List<List<String>>`.

Web URLs owned by other teams are rarely clean. When a deep link stuffs a Base64-encoded thumbnail, a custom `key:value` filter token, or a comma-separated ID list into a single URL parameter, extend `DeepLinkSerializer<T>`:

```kotlin title="BitmapSerializer.kt"
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.navigation3.runtime.deeplink.DeepLinkSerializer
import java.io.ByteArrayOutputStream

object BitmapSerializer : DeepLinkSerializer<Bitmap>() {
    override val serialName: String
        get() = "Bitmap"

    override fun deserialize(value: String): Bitmap {
        val imageBytes = Base64.decode(value, Base64.DEFAULT)
        return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
    }

    override fun serialize(value: Bitmap): String {
        val outputStream = ByteArrayOutputStream()
        value.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
        return Base64.encodeToString(outputStream.toByteArray(), Base64.DEFAULT)
    }
}

@Serializable
data class SessionWithPreview(
    val id: String,
    @Serializable(with = BitmapSerializer::class)
    val placeholderImage: Bitmap? = null
) : NavKey
```

`DeepLinkSerializer<T>` implements `KSerializer<T>` for single-string inputs, reducing custom serialization to three members: a unique `serialName`, `deserialize(value: String): T` for incoming URLs, and `serialize(value: T): String` so the same `NavKey` can still be persisted by `rememberNavBackStack` across configuration changes and process death (backed by `SavedState`, similar to [SavedStateHandle](/posts/savedstatehandle-savedstatecontainer/)). Keep any `Bitmap` or custom payload tiny; large objects stored in saved state risk hitting Android Binder transaction limits.

## Preprocessing raw arguments before serialization

Writing a full custom serializer is overkill when a URL carries a large JSON query parameter (`?session={"id":"42","tracking":"..."}`) and your screen only cares about one field inside it. `UriDeepLinkMatcher` is an `open` class with three hook methods: `matchRequest`, `matchUri`, and `matchArguments`. Overriding `matchArguments` lets you rewrite the extracted `Map<String, List<String>>` maps before `kotlinx.serialization` runs:

```kotlin title="SessionUriDeepLinkMatcher.kt"
class SessionUriDeepLinkMatcher(
    uriPattern: DeepLinkUri
) : UriDeepLinkMatcher<Session>(
    uriPattern = uriPattern,
    serializer = serializer<Session>()
) {
    override fun matchArguments(
        pathArgs: Map<String, List<String>>,
        queryArgs: Map<String, List<String>>,
        fragmentArgs: Map<String, List<String>>
    ): UriMatchResult<Session>? {
        val cleanedQueryArgs = buildMap {
            putAll(queryArgs)
            val jsonBlob = remove("session")?.firstOrNull()
            if (jsonBlob != null) {
                put("id", listOf(extractSessionIdFromJson(jsonBlob)))
            }
        }
        return super.matchArguments(pathArgs, cleanedQueryArgs, fragmentArgs)
    }
}
```

By extracting the scalar ID in `matchArguments`, the `Session` key stays decoupled from the external JSON schema.

## Building lazy synthetic back stacks with withBackStack

Dropping a user onto `listOf(Session(id = 42))` from a notification means pressing the system Back button immediately exits the app. Users expect a synthetic back stack that mirrors the hierarchy they would have traversed manually, such as `Home -> SessionList -> Session(42)`, while a deep link into a nested settings screen might need `Home -> Settings -> NotificationSettings`.

Tying back stack construction to each matcher solves the hierarchy problem, but computing synthetic back stacks for every candidate match wastes startup time when only one match wins. Navigation 3 avoids eager allocation through the `withBackStack` extension:

```kotlin title="SyntheticBackStackMatchers.kt"
import androidx.navigation3.runtime.deeplink.BackStackMatchResult
import androidx.navigation3.runtime.deeplink.BackStackMatcher
import androidx.navigation3.runtime.deeplink.withBackStack

val homeMatcher = UriDeepLinkMatcher(
    uriPattern = DeepLinkUri("ads.android.com/home"),
    serializer = serializer<Home>()
).withBackStack { matchResult ->
    listOf(matchResult.key)
}

val sessionMatcher = UriDeepLinkMatcher(
    uriPattern = DeepLinkUri("ads.android.com/session/{id}"),
    serializer = serializer<Session>()
).withBackStack { matchResult ->
    listOf(Home, SessionList, matchResult.key)
}

val backStackMatchers: List<BackStackMatcher<out NavKey, *>> = listOf(
    homeMatcher,
    sessionMatcher
)

val bestMatch: BackStackMatchResult<out NavKey, *>? = backStackMatchers
    .mapNotNull { matcher -> matcher.match(request) }
    .maxOrNull()

val initialBackStack: List<NavKey> = bestMatch?.backStack ?: listOf(Home)
```

`withBackStack` wraps the underlying matcher in a `BackStackMatcher` and delegates `Comparable` ranking to the inner `MatchResult`. The lambda that builds `listOf(Home, SessionList, matchResult.key)` only executes on the winning `bestMatch` when you read `.backStack`.

## Pre-filtering requests, static keys, and RequestExtras

Not every deep link needs URI pattern parsing. A photo editor handling `Intent.ACTION_VIEW` for `image/jpg` shares only cares about the MIME type, and an app with A/B-tested onboarding routes might branch on external experiment flags.

`DeepLinkMatcher.Filter` attaches preconditions (`DeepLinkMatcher.actionFilter(...)`, `DeepLinkMatcher.mimeTypeFilter(...)`, or a custom SAM lambda) to any matcher so failed checks short-circuit before URI parsing or Kotlin Serialization runs. When no URI parsing is needed at all, `StaticKeyDeepLinkMatcher` returns a fixed `NavKey` whenever its filters pass. Do not use `StaticKeyDeepLinkMatcher` for fixed web URLs like `ads.android.com/home`; keep `UriDeepLinkMatcher` for web URLs so its `UriMatchResult` ranks accurately against parameterized URI patterns.

To feed external signals into those filters, `RequestExtras` provides a typed key-value container built with `RequestExtrasKey<T>` and the `requestExtras { ... }` DSL:

```kotlin title="FiltersAndRequestExtras.kt"
import androidx.navigation3.runtime.deeplink.DeepLinkMatcher
import androidx.navigation3.runtime.deeplink.RequestExtrasKey
import androidx.navigation3.runtime.deeplink.StaticKeyDeepLinkMatcher
import androidx.navigation3.runtime.deeplink.requestExtras

@Serializable
data object ImagesKey : NavKey

object ExperimentBucketKey : RequestExtrasKey<String>

val jpegFilter = DeepLinkMatcher.mimeTypeFilter("image/jpg")
val experimentFilter = DeepLinkMatcher.Filter { req ->
    req.extras[ExperimentBucketKey] == "variant_b"
}

val imageShareMatcher = StaticKeyDeepLinkMatcher(
    key = ImagesKey,
    filters = listOf(jpegFilter, experimentFilter)
)

val extras = requestExtras {
    put(ExperimentBucketKey, "variant_b")
}
```

Because `DeepLinkRequest`, `UriDeepLinkMatcher`, `StaticKeyDeepLinkMatcher`, `BackStackMatcher`, and `RequestExtras` all live in Navigation 3's `commonMain` source set, your entire routing and synthetic back stack setup can be shared across Android and other Kotlin Multiplatform targets, leaving only `DeepLinkRequest(intent)` at the Android `Activity` boundary.
