Navigation3 1.1.4 hit stable on July 1, 2026. If you've been on Jetpack Navigation 2.x for a while — and at this point almost every production Android app has — this is the moment to start paying attention. Not because you need to migrate tomorrow, but because the mental model is different enough that waiting until you're forced to learn it under deadline pressure isn't a great plan.
This isn't a point release bump. Navigation3 is a ground-up rewrite. Different package, different concepts, different philosophy about who owns the backstack. Some of those differences are genuinely better. A few require you to unlearn things you've internalized. Either way, understanding what changed matters.
Why Navigation 2.x Needed a Rethink
Navigation 2.x was designed in an era when Fragment was the standard unit of Android UI. The library manages a FragmentManager backstack under the hood, and the nav graph — whether XML or Kotlin DSL — is fundamentally a graph of Fragment destinations with string-keyed arguments. When Compose arrived, the team built NavHost to bridge into it, but the underlying contract never changed. Compose composables became destinations inside a system that still thought in Fragments.
That mismatch produced a handful of real friction points. String routes are error-prone and don't survive refactoring without you noticing at runtime rather than compile time. The backstack is opaque — NavController owns it and you query it rather than driving it. Deep link handling is tangled into the graph definition in ways that make testing harder than it should be. And KMP was an afterthought: Navigation 2.x is Android-only, full stop.
None of this is fatal in a well-maintained app. But each of those friction points compounds over time, and the workarounds — type-safe argument plugins, manual backstack observation, hand-rolled result passing between destinations — add up to a meaningful amount of infrastructure that teams maintain and new hires have to learn.
The New Core Concepts
Navigation3 ships four concepts that replace everything in Navigation 2.x. Understanding them in order makes the rest click.
NavKey — type-safe destinations
Instead of string routes, destinations are @Serializable Kotlin objects. The type system enforces correctness at compile time, and serialization handles process death recovery automatically.
@Serializable
object Home : NavKey
@Serializable
data class ArticleDetail(val articleId: String) : NavKey
@Serializable
data class UserProfile(val userId: Int) : NavKey
Passing arguments to a destination is just passing constructor parameters to a data class. No argument bundles, no type-safe-args plugin, no code generation you need to configure separately. The @Serializable annotation does the heavy lifting, and since kotlinx.serialization handles it, you get KMP compatibility for free.
NavBackStack — you own the stack
This is the biggest conceptual shift. In Navigation 2.x, NavController owns the backstack and you call methods on it to navigate. In Navigation3, NavBackStack is plain observable state that you manage directly. The navigation library renders it; you mutate it.
val backStack = rememberNavBackStack(Home)
// navigate forward
backStack.add(ArticleDetail(articleId = "kotlin-coroutines-2026"))
// go back
backStack.pop()
// clear to root
backStack.clear()
backStack.add(Home)
This makes the backstack testable without an Android instrumented test — it's just a list. It also means deep link handling is explicit: you receive an intent, decode it into one or more NavKeys, and push them onto the stack. No magic XML intent filter wiring inside the nav graph.
NavDisplay — the renderer
NavDisplay is the composable that takes the backstack and renders it. You provide an entryProvider that maps each NavKey type to a composable:
NavDisplay(
backStack = backStack,
entryProvider = entryProvider {
entry {
HomeScreen(
onOpenArticle = { id -> backStack.add(ArticleDetail(id)) }
)
}
entry { key ->
ArticleScreen(
articleId = key.articleId,
onBack = { backStack.pop() }
)
}
entry { key ->
ProfileScreen(userId = key.userId)
}
}
)
One thing worth noting: navigation callbacks flow in as lambdas, not through NavController. Your screen composables don't need to know about NavKeys or the backstack at all. They receive typed parameters and call lambdas — which means they're composable in previews and unit tests without any navigation setup. This is the pattern Clean Architecture calls for anyway, so if you've been following it in Nodat or similar apps, the transition is natural.
Scene and SceneStrategy — controlling the layout
Scene determines how a navigation entry is displayed on screen. Navigation3 ships three built-in strategies:
- SinglePaneScene — full-screen content, the default for phones
- DialogScene — floating dialog overlay, replaces the old dialog destinations in Navigation 2.x
- OverlayScene — custom overlay with an
onRemovedcallback for exit animations
You can compose multiple strategies in a single NavDisplay, and the library picks the first one that matches each entry:
NavDisplay(
backStack = backStack,
sceneStrategies = listOf(
DialogSceneStrategy(),
OverlayStrategy(),
SinglePaneStrategy()
),
entryProvider = entryProvider { /* ... */ }
)
On a tablet you'd swap SinglePaneStrategy for a two-pane strategy that renders the backstack split across master and detail panes — without changing a single line of your screen composables. The layout policy lives in the navigation layer; the screens know nothing about it.
The adaptive story here is genuinely good. In Navigation 2.x, building a master-detail layout for tablets meant either a completely separate nav graph or brittle logic checking screen width inside your Fragment. Navigation3 makes that a SceneStrategy swap, which is the right place for it.
What's New Specifically in the 1.1.x Stable Line
The 1.0.x series established the core APIs. The 1.1.x stable line (capping at 1.1.4) added a handful of things that matter for production use:
Shared element transitions
You can now animate shared UI elements between navigation entries — the kind of Hero image transitions that have required third-party libraries or manual Crossfade workarounds until now:
NavDisplay(
backStack = backStack,
sharedTransitionScope = rememberSharedTransitionScope(),
entryProvider = entryProvider {
entry { ArticleListScreen() }
entry { ArticleDetailScreen() }
}
)
The SharedTransitionScope flows through the entry provider automatically, so individual screen composables can declare Modifier.sharedElement() without knowing they're inside a navigation stack.
SceneDecoratorStrategy
A new separation of concerns for wrapping scenes with common UI — bottom nav bars, app bars, snackbar hosts — that should be present across multiple destinations without being part of any individual screen composable:
NavDisplay(
backStack = backStack,
sceneStrategies = listOf(
SceneDecoratorStrategy { content ->
Scaffold(
bottomBar = { MainBottomNav(backStack) }
) { padding ->
Box(Modifier.padding(padding)) { content() }
}
},
SinglePaneStrategy()
),
entryProvider = entryProvider { /* ... */ }
)
This replaces the common pattern of putting a Scaffold at the NavHost level and then trying to selectively hide the bottom bar for certain destinations by observing the current route. The decorator strategy is explicit and composable.
NavMetadata DSL
Type-safe key-value metadata for entries, used internally by SceneStrategy to decide how to display a given destination, and available for your own custom logic:
entry {
CheckoutScreen()
metadata {
set(HideBottomNavKey, true)
set(StatusBarStyleKey, StatusBarStyle.Light)
}
}
Predictive back is supported out of the box via NavigationBackHandler. It hooks into Android's predictive back gesture APIs so the swipe-to-go-back animation previews the previous screen — behavior users expect on Android 13+ and that Navigation 2.x only supports through separate configuration.
State Preservation Across Process Death
The backstack serializes automatically when you pass a serializer:
val backStack = rememberNavBackStack(
initialKey = Home,
serializer = serializer()
)
Since your keys are @Serializable data classes, the entire backstack survives process death and activity recreation without any additional work. In Navigation 2.x this was handled internally by the Fragment backstack, which was one of the few things that actually worked well. Navigation3 makes the same guarantee explicit, and since you own the state object, you can also snapshot it for other purposes — analytics, A/B test instrumentation, debugging.
Getting Set Up
// build.gradle.kts
dependencies {
implementation("androidx.navigation3:navigation3-runtime:1.1.4")
implementation("androidx.navigation3:navigation3-ui:1.1.4")
// kotlinx.serialization required for NavKey
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:<version>")
}
The packages are androidx.navigation3.runtime and — not androidx.navigation. These are separate artifacts that coexist with Navigation 2.x, so you can migrate incrementally: new screens go to Navigation3, existing screens stay on 2.x until you're ready.
When to Migrate — My Honest Take
The stable 1.1.4 release is a real signal. The Google team has committed to the API shape; you're not going to rename NavBackStack to something else before 2.0. That means investing time to learn this now pays off.
That said, I wouldn't drop everything to migrate a production app tomorrow. A few things to consider:
- If you're starting a new app, use Navigation3 from day one. The old library's advantages disappear when you're not already invested in it.
- If you have a complex existing nav graph, do an incremental migration. Navigation3 and Navigation 2.x coexist. Start by routing new feature flows through Navigation3, and pay down the rest over a few sprints.
- Deep linking needs manual work. The explicit
DeepLinkRequest+DeepLinkMatcherpattern (landing in 1.2.0-alpha) gives you more control, but if your existing deep link setup is working, it's not a free migration. Budget time for it. - KMP is the long game. If you're planning to share navigation logic across Android and Desktop or iOS, Navigation3 is the only path. Navigation 2.x will never support KMP.
For Nodat's next major version, I'm planning to migrate the main nav graph to Navigation3 precisely because of the KMP angle — the domain and use case layers are already platform-agnostic, and having navigation logic that can be verified in JVM unit tests rather than instrumented tests is worth the migration cost. The type-safe NavKeys and developer-controlled backstack also eliminate a category of boilerplate I've maintained for years.
Navigation3 isn't a flashy release. It's the kind of foundational work that pays dividends quietly over the next two or three years — better compile-time safety, better testability, cleaner adaptive layouts, and a path to KMP that the old library could never offer. The stable label on 1.1.4 means the time to get familiar with it is now, not when you're forced to.
No comments yet. Be the first to leave one!