Compose

Migrating Android Navigation: XML to Navigation Compose, Then Navigation 2 to Navigation 3

Ahmad Najar Ahmad Najar 5 min read

Two Migrations, One Destination

Android navigation has gone through three real eras: XML + Fragments, Navigation Compose ("Nav 2"), and now Navigation 3 — a ground-up rebuild designed exclusively for Compose. If you're still on XML, you likely need to make both jumps. This guide walks through each one separately, with full working code, so you can stop at Nav 2 or go all the way to Nav 3.

Part 1: XML/Fragments → Navigation Compose (Nav 2)

What you're leaving behind

The classic setup: a nav_graph.xml resource, a NavHostFragment in your activity layout, Fragment destinations, and Safe Args for passing arguments between them. It works, but it means maintaining XML graphs alongside Compose UI, and Safe Args' generated classes never quite feel native to Kotlin.

Step 1: Swap dependencies

dependencies {
    implementation("androidx.navigation:navigation-compose:2.8.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
}

Also apply the Kotlin serialization plugin — type-safe routes in Nav 2 use @Serializable classes instead of Safe Args-generated ones.

Step 2: Replace your nav graph with typed route classes

Delete nav_graph.xml. Each destination becomes a serializable class or object:

@Serializable object Home
@Serializable object Settings
@Serializable data class Profile(val userId: String)

This alone removes an entire category of bugs — no more mistyped XML action IDs or bundle keys.

Step 3: Replace NavHostFragment with NavHost

@Composable
fun AppNavHost() {
    val navController = rememberNavController()

    NavHost(navController = navController, startDestination = Home) {
        composable {
            HomeScreen(onOpenProfile = { id -> navController.navigate(Profile(id)) })
        }
        composable { backStackEntry ->
            val profile: Profile = backStackEntry.toRoute()
            ProfileScreen(userId = profile.userId, onBack = { navController.popBackStack() })
        }
        composable {
            SettingsScreen()
        }
    }
}

NavHost replaces the fragment container, and composable<T> replaces each <fragment> tag. navController.navigate(Profile(id)) replaces both the Safe Args-generated navigation call and the argument bundle in one step.

Step 4: Migrate Fragment lifecycle logic into ViewModels

Anything that lived in onViewCreated or onCreateView generally becomes state collected in the composable, sourced from a ViewModel scoped with hiltViewModel() or viewModel(). There's no fragment lifecycle to hook into anymore — Compose's own recomposition and LaunchedEffect take over that role.

Step 5: Rebuild bottom navigation, if you have it

val items = listOf(Home, Settings)
val backStackEntry by navController.currentBackStackEntryAsState()

NavigationBar {
    items.forEach { route ->
        val selected = backStackEntry?.destination?.hierarchy?.any { it.hasRoute(route::class) } == true
        NavigationBarItem(
            selected = selected,
            onClick = { navController.navigate(route) { launchSingleTop = true } },
            icon = { /* ... */ },
            label = { /* ... */ }
        )
    }
}

Common pitfalls moving off XML

  • Forgetting launchSingleTop on bottom-nav destinations — without it, tapping the same tab repeatedly stacks duplicate screens.
  • Losing saved state on tab switches — add saveState = true / restoreState = true to navigate() calls if you want tab back stacks to persist like they did with multiple back stacks in the old system.
  • Deep links — these move from <deepLink> XML tags to a deepLinks parameter on composable<T>, using the same typed route.

Part 2: Navigation Compose (Nav 2) → Navigation 3

Why this second jump exists

Nav 2's NavController was designed around Fragment-era assumptions from years before Compose existed. It owns your back stack internally and exposes it as a semi-opaque object. Navigation 3 flips this: you own the back stack yourself, as a plain observable list. Nav 3 doesn't manage state for you — it renders whatever state you hand it. That's the entire philosophical shift, and it's what makes multi-pane/adaptive layouts and custom back-stack logic dramatically easier.

Before you start: prerequisites

  • compileSdk 36 or later, and minSdk raised to 23.
  • Your routes must already be type-safe (@Serializable classes) — string-based routes need to be migrated first.
  • Nav 3 is Compose-only. If you still have Fragments or Views anywhere in the graph, wrap them with Compose interop first.
  • This migration is meant to happen atomically — Nav 2 and Nav 3 aren't designed to run side by side in the same graph.

Step 1: Add Navigation 3 dependencies

[versions]
nav3Core = "1.0.0"
lifecycleViewmodelNav3 = "2.10.0-rc01"

[libraries]
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }

Step 2: Make every route implement NavKey

// Before
@Serializable object Home

// After
@Serializable object Home : NavKey

Implementing NavKey unlocks rememberNavBackStack, which is how Nav 3 persists your back stack across rotation and process death.

Step 3: Build your own navigation state holder

This is the biggest mental shift. Instead of a NavController, you write a small state holder that tracks one back stack per top-level destination (e.g. one per bottom-nav tab):

class AppNavState(
    val startRoute: NavKey,
    topLevelRoute: MutableState<NavKey>,
    val backStacks: Map<NavKey, NavBackStack<NavKey>>
) {
    var topLevelRoute: NavKey by topLevelRoute
    val activeStacks: List<NavKey>
        get() = if (topLevelRoute == startRoute) listOf(startRoute) else listOf(startRoute, topLevelRoute)
}

@Composable
fun rememberAppNavState(startRoute: NavKey, topLevelRoutes: Set<NavKey>): AppNavState {
    val topLevelRoute = rememberSerializable(
        startRoute, topLevelRoutes,
        serializer = MutableStateSerializer(NavKeySerializer())
    ) { mutableStateOf(startRoute) }

    val backStacks = topLevelRoutes.associateWith { rememberNavBackStack(it) }

    return remember(startRoute, topLevelRoutes) {
        AppNavState(startRoute, topLevelRoute, backStacks)
    }
}

And a thin class to mutate it — this replaces every navController.navigate()/popBackStack() call in your app:

class AppNavigator(private val state: AppNavState) {
    fun navigate(route: NavKey) {
        if (route in state.backStacks.keys) {
            state.topLevelRoute = route
        } else {
            state.backStacks[state.topLevelRoute]?.add(route)
        }
    }

    fun goBack() {
        val stack = state.backStacks.getValue(state.topLevelRoute)
        if (stack.last() == state.topLevelRoute) {
            state.topLevelRoute = state.startRoute
        } else {
            stack.removeLastOrNull()
        }
    }
}

Step 4: Translate NavController calls

Nav 2 (NavController)Nav 3 equivalent
navigate()AppNavigator.navigate()
popBackStack()AppNavigator.goBack()
currentBackStackEntrybackStacks[topLevelRoute]?.last()
checking selected bottom-nav tabroute == navState.topLevelRoute

Step 5: Replace the NavGraphBuilder DSL with entryProvider

// Before (Nav 2)
NavHost(navController, startDestination = Home) {
    composable<Home> { HomeScreen() }
    composable<Profile> { entry -> ProfileScreen(entry.toRoute<Profile>().userId) }
    dialog<ConfirmDelete> { ConfirmDeleteDialog() }
}

// After (Nav 3)
val entryProvider = entryProvider {
    entry<Home> { HomeScreen() }
    entry<Profile> { key -> ProfileScreen(key.userId) }
    entry<ConfirmDelete>(metadata = DialogSceneStrategy.dialog()) { ConfirmDeleteDialog() }
}

Note there's no nested "base route" concept anymore — a top-level route in your AppNavState map already identifies which stack a screen belongs to, so nested navigation<T> { } wrappers just get deleted.

Step 6: Replace NavHost with NavDisplay

NavDisplay(
    entries = navState.toEntries(entryProvider),
    onBack = { navigator.goBack() },
    sceneStrategies = remember { listOf(DialogSceneStrategy()) }
)

NavDisplay is a pure rendering layer — it takes whatever list of entries your state produces and draws them. It doesn't own or mutate your back stack the way NavHost implicitly did.

Step 7: Delete Nav 2

Remove the androidx.navigation:navigation-compose dependency and every leftover NavController/NavHostController import. If anything still references them, the migration isn't finished.

What Nav 3 doesn't cover yet

Worth knowing before you commit a whole app to this migration:

  • Deep links aren't supported out of the box yet.
  • More than one level of nested navigation isn't covered by the standard migration path.
  • Shared destinations that move between different back stacks need custom handling.
  • Bottom sheets, ViewModel argument injection, and returning results from a screen are all supported, but only through recipes in the official nav3-recipes repo rather than the core library.

Should you migrate now?

Navigation 3 hit stable in late 2025, so it's production-viable — but "stable" doesn't mean "covers everything Nav 2 did." If your app leans heavily on deep links or deeply nested graphs, budget extra time for the recipe-based workarounds, or wait until those land in core. If your navigation is relatively flat — a handful of top-level tabs, each with its own stack — this migration is genuinely worth doing: less magic, more control, and a data model that finally matches how Compose thinks about state everywhere else.

Compose Coding