Skip to content
AKarda-kazancimainbrew()
Personal blog
AK
arda-kazanci › journal ›type-safe-deep-links-jetpack-navigation-3.md
← Journal.md
type-safe-deep-links-jetpack-navigation-3.md273 lines · Markdown · UTF-8
Markdown Preview · type-safe-deep-links-jetpack-navigation-3.md Rendered
journal/type-safe-deep-links-jetpack-navigation-3.md

Type-Safe Deep Links in Jetpack Navigation 3: UriDeepLinkMatcher, DeepLinkSerializer, and Synthetic Back Stacks

How androidx.navigation3:navigation3-runtime 1.2+ maps DeepLinkRequest instances to type-safe NavKey back stacks using UriDeepLinkMatcher, DeepLinkSerializer, and lazy withBackStack builders.

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 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, 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 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:

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:

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:

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:

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:

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>:

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). 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:

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:

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:

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.

SHARE ENTRYLinkedIn ↗X ↗Markdown / AI ↗
Discussions ardakazanci/blog-comments
● GiscusView discussions on GitHub ↗
⑂Reactions & comments/ Sign in with GitHub to react or join the discussion.
Notifications Newsletter · beehiiv
✓ Active channelSubscribe directly ↗
✉Get new posts by email/ Android, Kotlin, and Compose notes. Straight to your inbox.

SUBSCRIPTION CONFIG · EMAIL DISPATCH

Stay synced with new entries

Receive practical engineering notes on Android architecture, Kotlin, and Jetpack Compose whenever a new article is published.

channel = "email"spam = falseunsubscribe = "anytime"
K subscriber.propertiesEmail address