Nav 2 → Nav 3 migration based on official docs. Nav 2 is not deprecated — migration is optional.
For Nav 3 full reference, see navigation-3.md. For Nav 2 full reference, see navigation-2.md. For shared concepts and decision guide, see navigation.md.
| Nav 2 | Nav 3 |
|---|---|
NavController owns the back stack |
You own the back stack (SnapshotStateList) |
NavHost renders composable destinations |
NavDisplay observes the back stack and renders entries |
Routes are strings or @Serializable types |
Keys are @Serializable types implementing NavKey |
Imperative navigation (navController.navigate()) |
List manipulation (backStack.add(), backStack.removeLastOrNull()) |
NavGraph groups destinations |
No separate graph — entries are resolved by the entryProvider |
| Deep links parsed by Navigation library | Deep links parsed by your code — you construct the back stack |
Graph-scoped ViewModels via getBackStackEntry() |
Entry-scoped ViewModels via rememberViewModelStoreNavEntryDecorator() |
currentBackStackEntryAsState() for selected tab |
Direct back stack inspection (backStack.last()) |
saveState/restoreState for tab persistence |
Persistent per-tab stacks or root swap pattern |
// Nav 2
@Serializable data object Home
@Serializable data class Detail(val id: String)
// Nav 3
@Serializable data object Home : NavKey
@Serializable data class Detail(val id: String) : NavKey
// Nav 2
val navController = rememberNavController()
navController.navigate(Detail(id))
// Nav 3
val backStack = rememberNavBackStack(Home)
backStack.add(Detail(id))
Replace NavHost + composable<T> with NavDisplay + entryProvider + entry<T>. Each composable block becomes an entry block; navController.navigate() becomes backStack.add(). For full NavDisplay API, decorators, and DI wiring, see navigation-3.md and navigation-3-di.md.
Nav 3 scopes ViewModels to entries automatically via rememberViewModelStoreNavEntryDecorator(). For shared state across entries, lift state to a parent composable or use a shared ViewModel at the Activity/App scope.
Nav 2 graph-scoped pattern:
val parentEntry = remember(entry) { navController.getBackStackEntry("checkout") }
val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
Nav 3 equivalent — lift to parent or share via DI:
// Option 1: shared ViewModel at a higher scope
val sharedViewModel: CheckoutViewModel = viewModel() // Activity-scoped
// Option 2: state hoisting in a parent composable
// The parent composable holds shared state, passes it to child entries
Nav 3 does not parse deep links — parse URIs in your platform entry point and construct the back stack manually:
// Nav 2
composable<Detail>(
deepLinks = listOf(navDeepLink<Detail>(basePath = "https://example.com/detail"))
) { /* ... */ }
// Nav 3
LaunchedEffect(deepLinkId) {
if (deepLinkId != null) {
backStack.clear()
backStack.addAll(listOf(Home, Detail(deepLinkId)))
}
}
// Nav 2 — NavigationBar + currentBackStackEntryAsState + saveState/restoreState
navController.navigate(tab.route) {
popUpTo(startDest) { saveState = true }
launchSingleTop = true
restoreState = true
}
// Nav 3 — direct back stack manipulation
while (backStack.size > 1) backStack.removeLast()
backStack[0] = targetTopLevelKey
You do not have to migrate everything at once. The official docs recommend:
navController.navigate() calls to backStack.add() calls one screen at a timeDuring migration, Nav 2 and Nav 3 can coexist in the same app. Use Nav 3 for new feature modules while keeping Nav 2 for existing screens. Bridge between them at the Activity level — a Nav 2 destination can launch an Activity/Fragment that hosts Nav 3, or vice versa.