I have a production app with four bottom tabs, nine navigation graphs, and 40+ screens. Deep links from push notifications. OAuth callbacks from third-party services. Shared ViewModels across multi-screen flows.
Navigation 2 handled all of this. Messily, with string routes and withArgs hacks, but it worked.
Then I tried migrating to Navigation 3.
The first screen took 20 minutes. The bottom navigation took three days. Deep links? I almost gave up.
Nav3 is genuinely better -- type-safe routes, a visible back stack you actually control, no more NavController black box. But the migration path has gaps that the docs don't warn you about. Shared ViewModels work differently. Deep links are now your problem. Returning results between screens has no built-in solution.
This is everything I learned migrating a production app. The good parts, the painful parts, and the workarounds I wish someone had written down before I started.
What Nav3 Actually Changes
If you've been using Navigation 2, here's the mental shift:
Back stack ownership:
Nav2 — NavController manages everything behind a black box.
Nav3 — You own a SnapshotStateList back stack. Fully visible, fully yours.
Route definitions:
Nav2 — String routes: "project_detail/{projectId}/{projectName}"
Nav3 — Data classes: ProjectDetail(projectId = 42L, projectName = "Alpha")
Screen registration:
Nav2 — NavHost + composable() DSL
Nav3 — NavDisplay + entryProvider
Argument types:
Nav2 — navArgument(type = NavType.LongType)
Nav3 — Just... a Long property on a data class
Reading arguments:
Nav2 — backStackEntry.arguments?.getLong("projectId")
Nav3 — key.projectId
Navigation calls:
Nav2 — navController.navigate(route) / navController.popBackStack()
Nav3 — backStack.add(route) / backStack.removeLastOrNull()
The argument passing alone made the migration worth it. Take a screen that needs a project ID and name. The Nav2 route looked like this:
// Nav2: string route with arguments
"project_detail/{projectId}/{projectName}"
Two navArgument declarations. Two backStackEntry.arguments?.getXxx() calls. One wrong type and you get a runtime crash with an unhelpful error message.
Nav3 equivalent:
// Nav3: just a data class
@Serializable
data class ProjectDetail(
val projectId: Long,
val projectName: String
) : NavKey
Compile-time safe. IDE autocomplete. No parsing.
Setting Up Nav3
Dependencies
# gradle/libs.versions.toml
[versions]
nav3 = "1.0.1"
lifecycleNav3 = "2.10.0-rc01"
[libraries]
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3" }
androidx-lifecycle-viewmodel-nav3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleNav3" }
// app/build.gradle.kts
dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.nav3)
}
You also need compileSdk = 36 and minSdk = 23. The minSdk bump from 21 caught me off guard -- I had to drop Android 4.x support. Check your analytics first.
Define Your Routes
Every route is now a @Serializable class implementing NavKey. No more string constants.
// navigation/Routes.kt
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
// ---- Bottom tabs ----
@Serializable data object HomeTab : NavKey
@Serializable data object ProjectsTab : NavKey
@Serializable data object MessagesTab : NavKey
@Serializable data object SettingsTab : NavKey
// ---- Auth ----
@Serializable data object Welcome : NavKey
@Serializable data object Login : NavKey
@Serializable data class VerifyOtp(val email: String, val token: String) : NavKey
// ---- Projects ----
@Serializable data class ProjectDetail(
val projectId: Long,
val projectName: String
) : NavKey
@Serializable data class TaskDetail(
val taskId: Long,
val taskTitle: String,
val projectId: Long
) : NavKey
Bottom Navigation with Multiple Back Stacks
This is where Nav3 gets interesting -- and where I spent three days.
In Nav2, each bottom tab's back stack was managed invisibly by NavController with saveState/restoreState. In Nav3, you manage each tab's back stack explicitly.
The Navigation State
class AppNavigationState(
val startTab: NavKey,
private val _currentTab: MutableState<NavKey>,
val tabStacks: Map<NavKey, NavBackStack<NavKey>>
) {
var currentTab: NavKey
get() = _currentTab.value
set(value) { _currentTab.value = value }
val activeStacks: List<NavKey>
get() = if (currentTab == startTab) {
listOf(startTab)
} else {
listOf(startTab, currentTab)
}
}
@Composable
fun rememberAppNavigationState(
startTab: NavKey = HomeTab,
tabs: Set<NavKey> = setOf(HomeTab, ProjectsTab, MessagesTab, SettingsTab)
): AppNavigationState {
val currentTab = rememberSaveable { mutableStateOf(startTab) }
val tabStacks = tabs.associateWith { tab -> rememberNavBackStack(tab) }
return remember(startTab, tabs) {
AppNavigationState(
startTab = startTab,
_currentTab = currentTab,
tabStacks = tabStacks
)
}
}
The Navigator
class AppNavigator(private val state: AppNavigationState) {
fun navigateTo(route: NavKey) {
if (route in state.tabStacks.keys) {
state.currentTab = route
} else {
state.tabStacks[state.currentTab]?.add(route)
}
}
fun goBack(): Boolean {
val currentStack = state.tabStacks[state.currentTab] ?: return false
val currentRoute = currentStack.lastOrNull() ?: return false
// At root of non-start tab? Go back to start tab
if (currentRoute == state.currentTab && state.currentTab != state.startTab) {
state.currentTab = state.startTab
return true
}
// At root of start tab? Let the system handle it (exit app)
if (currentRoute == state.startTab) {
return false
}
currentStack.removeLastOrNull()
return true
}
fun switchTab(tab: NavKey) {
state.currentTab = tab
}
}
The Hard Parts (And Workarounds)
1. Deep Links Are Now Your Problem
Nav2 had navDeepLink { uriPattern = "myapp://projects/{projectId}" }. Nav3 has nothing built in. You parse intents yourself and push routes onto the back stack.
object DeepLinkHandler {
fun handleIntent(intent: Intent, navigator: AppNavigator): Boolean {
val uri = intent.data ?: return false
return when {
uri.pathSegments.firstOrNull() == "projects" -> {
val projectId = uri.pathSegments.getOrNull(1)?.toLongOrNull() ?: return false
val projectName = uri.getQueryParameter("name") ?: "Unknown"
navigator.switchTab(ProjectsTab)
navigator.navigateTo(ProjectDetail(projectId = projectId, projectName = projectName))
true
}
else -> false
}
}
}
2. Returning Results Between Screens
Nav2 had previousBackStackEntry?.savedStateHandle. Nav3 doesn't. Here's the pattern I use:
// 1. Define a result holder
class SelectMemberResult {
var selectedMemberId: Long? by mutableStateOf(null)
}
// 2. Provide it via CompositionLocal
val LocalSelectMemberResult = staticCompositionLocalOf { SelectMemberResult() }
// 3. In the picker screen, set the result and go back
entry<SelectMemberForTeam> {
val result = LocalSelectMemberResult.current
SelectMemberScreen(
onMemberSelected = { memberId ->
result.selectedMemberId = memberId
navigator.goBack()
}
)
}
3. Hilt ViewModels
Nav3 + Hilt works, but route arguments no longer appear in SavedStateHandle. You pass them manually:
// DON'T rely on SavedStateHandle for route args in Nav3:
@HiltViewModel
class ProjectDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle // Empty! No route args here
) : ViewModel()
// DO pass the key directly:
entry<ProjectDetail> { key ->
val viewModel: ProjectDetailViewModel = hiltViewModel()
LaunchedEffect(key) {
viewModel.initialize(key.projectId, key.projectName)
}
ProjectDetailScreen(viewModel = viewModel, onBack = { navigator.goBack() })
}
4. Screen Transition Animations
Nav3 has built-in support for transitions, including predictive back gesture animations.
NavDisplay(
entries = entries,
onBack = { navigator.goBack() },
transitionSpec = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start) togetherWith
slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start)
},
popTransitionSpec = {
slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End) togetherWith
slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End)
}
)
Predictive back -- where the previous screen peeks as you swipe -- works out of the box.
The Migration Checklist
If you're migrating from Nav2, here's the order that worked for me:
- Add Nav3 dependencies alongside Nav2 -- they don't conflict
-
Convert route definitions to
@Serializable+NavKeydata classes -
Build
NavigationStateandNavigatorfor your bottom tabs - Migrate one tab at a time -- start with the simplest one
-
Move deep link handling to your own
DeepLinkHandler -
Convert result-passing screens to
CompositionLocalpattern - Remove Nav2 dependencies after all screens are migrated
- Test predictive back on every screen
Quick Reference
Nav2 Nav3
────────────────────────────────────────────────────────
NavHost { } → NavDisplay(entries, onBack)
composable<Route> { } → entry<Route> { key -> }
navController.navigate(r) → backStack.add(r)
navController.popBackStack() → backStack.removeLastOrNull()
NavType.LongType → just a Long property
navDeepLink { } → manual intent parsing
savedStateHandle result → CompositionLocal or shared flow
navigation<Parent> { } → gone (use entry decorators)
When NOT to Migrate
Don't migrate if:
- Your app uses Fragments or Views -- Nav3 is Compose-only
- You depend heavily on nested graph scoping -- still rough in Nav3
- You need deep links handled by the framework -- you'll write that yourself
- Your team is mid-sprint -- budget a week for 40 screens
Do migrate if:
- You're starting a new Compose project
- You're tired of runtime crashes from wrong argument types
- You want predictive back gestures without extra work
- You want to actually see your back stack
Wrapping Up
Nav3 is what Navigation for Compose should have been from the start. The back stack is yours. The routes are type-safe. Arguments are just properties.
For new projects, use Nav3. For existing apps, migrate when you have the bandwidth -- it's worth it, but budget a week, not an afternoon.
For the Nav2 approach with type-safe routes, popUpTo, and nested graphs, check out the Jetpack Compose Navigation tutorial at https://www.suridevs.com/blog/posts/navigation-component-jetpack-compose-complete-guide/. For structuring your ViewModels behind these screens, see the MVVM Architecture guide at https://www.suridevs.com/blog/posts/mvvm-jetpack-compose-authentication-guide/.
Originally published at SuriDevs
Top comments (0)