Nav 9 min read

Navigation3 1.2.0-alpha07: BackStackMatcher Solves the Deep Link Back-Stack Problem

Every app that handles deep links faces the same embarrassing moment: a user taps your push notification, lands three screens deep, presses Back — and exits the app entirely. The navigation stack is empty because the deep link just pushed one destination. You have to manually synthesize the back stack from scratch, figuring out what "context" the user should have when they navigate up from wherever the link dropped them.

Navigation3 1.2.0-alpha07, released on July 29, 2026, ships BackStackMatcher — a declarative API that moves synthetic back-stack construction right into the deep link matching layer. Instead of wiring up back stack initialization logic somewhere in your Activity's onCreate and hoping you covered every case, you describe the back stack shape as part of the matcher definition. Alpha07 also adds DeepLinkSerializer<T> for non-primitive argument types and five targeted bug fixes, many in the deep link plumbing added in alpha05/06. Navigation3 1.1.5 also shipped as a stable patch release on the same day.

New
BackStackMatcher builds a synthetic back stack at match time
New
DeepLinkSerializer for complex non-primitive argument types
5
Bug fixes for deep link edge cases in alpha05/06

The Back-Stack Problem with Deep Links

Before digging into the API, it helps to be precise about the problem. When a user opens your app normally, each navigation step adds to the back stack. Pressing Back walks back through real destinations the user visited. That's the happy path.

When an external trigger — a notification, an App Link, a widget shortcut — sends the user to a specific screen, your app may have an empty back stack. The user is at ArticleDetailKey with nothing behind it. Pressing Back exits. That's the broken path, and it's the one that generates the most 1-star reviews in the "navigation feels broken" category.

The traditional fix is to detect the deep link in onCreate, decide what the "synthetic" path to the destination should be, and push multiple destinations onto the back stack before the UI even renders. In Nodat, for example, a notification deep link into a specific note should leave the note list behind it so the user can navigate up naturally. In Musist, a share-target deep link into a track detail screen should put the album view behind it. This logic is always app-specific, always manual, and often lives in a place that's easy to forget to update when the navigation graph changes.

Navigation3 1.2.0-alpha07 makes this declarative by attaching back-stack construction to the matcher itself.

BackStackMatcher — Declarative Synthetic Back Stacks

The new API is a factory extension on DeepLinkMatcher: DeepLinkMatcher.withBackStack(). You wrap an existing matcher and provide a lambda that receives the MatchResult and returns the full list of NavKey instances that should form the back stack — from bottom to top.

@Serializable object HomeKey
@Serializable data class NoteListKey(val folderId: Int? = null)
@Serializable data class NoteDetailKey(val noteId: String)

// Base matcher resolves the URI to a NoteDetailKey
val noteDetailMatcher = UriDeepLinkMatcher(
    uriPattern = "https://nodat.app/notes/{noteId}",
    navKey = NoteDetailKey::class
)

// Wrap it with a back stack builder
val noteDeepLinkMatcher = DeepLinkMatcher.withBackStack(
    matcher = noteDetailMatcher,
    builder = { matchResult ->
        // matchResult.key is the NoteDetailKey extracted from the URI
        listOf(
            HomeKey,
            NoteListKey(),     // note list with no folder filter
            matchResult.key    // the specific note the notification pointed to
        )
    }
)

The builder lambda receives a BackStackMatchResult — a subtype of the inner matcher's result — so if you're wrapping a UriDeepLinkMatcher whose result contains typed arguments, you can use them to construct context-appropriate parent destinations. That matters when the parent should be scoped differently based on the deep link payload. A notification that deep links to an article in the "science" section should probably place the "science" category list behind the article, not the generic top-level feed.

@Serializable data class CategoryListKey(val category: String)
@Serializable data class ArticleKey(val articleId: Int, val category: String)

val articleMatcher = UriDeepLinkMatcher(
    uriPattern = "https://samachar.app/{category}/article/{articleId}",
    navKey = ArticleKey::class
)

val articleDeepLinkMatcher = DeepLinkMatcher.withBackStack(
    matcher = articleMatcher,
    builder = { result ->
        val key = result.key  // ArticleKey with category + articleId populated
        listOf(
            HomeKey,
            CategoryListKey(category = key.category),
            key
        )
    }
)

In Samachar, where YouTube-API-backed news articles are organized by category, this is exactly the pattern I've been implementing manually. The category is already in the URI — the notification that deep links to an article includes the section in the path — so building the parent context is just reading from the resolved key. With BackStackMatcher, that logic moves into the matcher definition rather than being scattered across the Activity.

Applying the Back Stack

The result of matching against a BackStackMatcher is a BackStackMatchResult. It contains both the original match result (so you keep access to the matched key and any extras) and a backStack list of NavKey instances ready to push.

// In your Activity or NavHost setup
val deepLinkMatchers = listOf(noteDeepLinkMatcher, articleDeepLinkMatcher)

val intent = intent
if (intent != null) {
    val request = DeepLinkRequest(intent = intent)
    val result = deepLinkMatchers.firstNotNullOfOrNull { it.match(request) }
    if (result is BackStackMatchResult) {
        // Replace the entire back stack with the synthetic one
        navBackStack.clear()
        navBackStack.addAll(result.backStack)
    } else if (result != null) {
        // Normal single-destination match (no back stack)
        navBackStack.add(result.key)
    }
}

The BackStackMatchResult also exposes the inner match result, so you still have access to the original typed key if you need to inspect it separately from the back stack list.

Process-death caveat: The synthetic back stack lands in NavBackStack, which Navigation3 serializes to rememberSaveable. That means it survives configuration changes and process death by default — the user can kill and reopen the app and still have the back stack. If your back stack contains keys with arguments that are expensive to restore or that become invalid (e.g., network-fetched IDs that expire), make sure the destinations handle that gracefully rather than assuming the key's payload is always fresh.

DeepLinkSerializer — Non-Primitive Argument Types

The deep link argument extraction in UriDeepLinkMatcher already handles primitives — Int, Long, Boolean, String — and their nullable counterparts. Alpha07 extends this to non-primitive types via a new abstract class: DeepLinkSerializer<T>.

// A custom enum type used as a deep link argument
enum class FeedFilter { ALL, SUBSCRIBED, TRENDING }

// Define a serializer for it
class FeedFilterSerializer : DeepLinkSerializer<FeedFilter>() {
    override fun deserialize(value: String): FeedFilter {
        return FeedFilter.valueOf(value.uppercase())
    }

    override fun serialize(value: FeedFilter): String {
        return value.name.lowercase()
    }
}

@Serializable data class FeedKey(
    @DeepLinkParam(serializer = FeedFilterSerializer::class)
    val filter: FeedFilter = FeedFilter.ALL
)

val feedMatcher = UriDeepLinkMatcher(
    uriPattern = "https://app.example.com/feed?filter={filter}",
    navKey = FeedKey::class
)

The DeepLinkSerializer contract is minimal: deserialize(String): T converts the raw URI string segment into your type, and serialize(T): String does the reverse (used when constructing deep link URIs programmatically). The class extends KSerializer, so it's compatible with rememberSaveable and the serialization the back stack uses for process-death survival — you don't need a separate serialization strategy for the key itself.

This matters for apps with complex routing where the URI encodes meaningful structured data. In Musist, for instance, a share link to a specific mix includes the audio quality and loop preferences as query parameters — data that maps to a sealed class, not a primitive. Before alpha07, deserializing these from the URI required custom parsing in onCreate outside the navigation layer. With DeepLinkSerializer, it folds cleanly into the key definition.

WrappedMatchResult — Composable Matchers

Alpha07 adds an abstract WrappedMatchResult class for building matchers that augment or layer on top of another matcher's result. Where BackStackMatcher is a specific policy (add a back stack), WrappedMatchResult is the extensibility primitive that lets you build other such policies.

// Example: a matcher that adds analytics metadata to any match result
class TrackedMatchResult<T : Any>(
    inner: DeepLinkMatcher.MatchResult<T>,
    val trackingSource: String
) : WrappedMatchResult<T>(inner)

// Wrap any matcher with analytics context
fun <T : Any> DeepLinkMatcher<T>.withTracking(source: String): DeepLinkMatcher<T> {
    return object : DeepLinkMatcher<T, TrackedMatchResult<T>>() {
        override fun match(request: DeepLinkRequest): TrackedMatchResult<T>? {
            val inner = this@withTracking.match(request) ?: return null
            return TrackedMatchResult(inner, source)
        }
    }
}

The use cases for this are more framework-builder than app-developer level — if you're building a navigation abstraction over Navigation3, this gives you the extension point. Most app developers will use BackStackMatcher directly and won't need to implement WrappedMatchResult themselves.

The Type Parameter Enhancement on DeepLinkMatcher

DeepLinkMatcher now carries a second covariant type parameter for the result type, so you can access subclassed MatchResult properties directly without downcasting. Before alpha07:

// Old — required a cast to access BackStackMatchResult properties
val result: DeepLinkMatcher.MatchResult<*> = matcher.match(request)
if (result is BackStackMatchResult) {
    val backStack = result.backStack  // fine after the cast
}
// New — the second type parameter carries the result type
val matcher: DeepLinkMatcher<NoteDetailKey, BackStackMatchResult<NoteDetailKey>>
val result = matcher.match(request)  // type: BackStackMatchResult<NoteDetailKey>?
val backStack = result?.backStack    // no cast needed

This is a welcome ergonomic fix. Once you compose multiple matchers in a firstNotNullOfOrNull call you lose the specific type anyway, but for typed pipelines where you apply a single matcher to a known request, it eliminates boilerplate.

Bug Fixes Worth Knowing

Five fixes shipped in alpha07, all in the deep link infrastructure introduced in alpha05/06:

Navigation3 1.1.5 stable also released July 29 — the same day as alpha07. If you're on 1.1.4 stable and not yet ready for the alpha series, update to 1.1.5 to pick up the stable-channel patch fixes. The changelog isn't fully published yet, but the version bump follows the standard Jetpack patch release pattern: only backported fixes, no API changes.

Updated Dependencies

dependencies {
    // Alpha series — deep links, result bus, back stack matcher
    implementation("androidx.navigation3:navigation3-runtime:1.2.0-alpha07")
    implementation("androidx.navigation3:navigation3-ui:1.2.0-alpha07")

    // Stable patch — if you're not yet ready for alpha
    // implementation("androidx.navigation3:navigation3-runtime:1.1.5")
    // implementation("androidx.navigation3:navigation3-ui:1.1.5")
}

When Does This Change My Migration Decision?

I wrote in the alpha05/06 post that new projects should start on the 1.2.0-alpha series because deep linking is a day-one feature for real apps, and the 1.1.4 stable gap was too big. Alpha07 makes that recommendation stronger.

The BackStackMatcher API specifically is the piece that, in my experience, teams always end up building themselves — a "build the context back stack for this deep link" function somewhere in the Activity or ViewModel. With alpha07, that logic has a proper home in the navigation layer. Once 1.2.x goes stable, apps using the old Navigation Component primarily because they needed declarative back-stack construction for deep links have one less reason to stay.

For existing apps on 1.1.4 stable, the calculus is the same as before: hold unless you're actively blocked. The BackStackMatcher and DeepLinkSerializer APIs are still alpha — the team is clearly iterating every few weeks (alpha05 was July 1, alpha06 July 15, alpha07 July 29), and each drop has had small breaking changes in the deep link surface. The direction is stable, but the exact API shape isn't finalized yet. If you adopt alpha07 today, build your matching layer with the assumption that the API names might shift slightly before 1.2.0 goes stable.

That said: if your app uses notification deep links and your users are currently landing with an empty back stack, alpha07 is worth the risk. The problem it solves is user-visible and real, and the fix is exactly as clean as you'd want it to be.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment