Two navigation systems can share one back stack, which is the only reason this works
Navigation Compose and the fragment navigator can coexist in one NavHost via the fragment interop artifact. That shared back stack is what makes an incremental screen-by-screen migration possible.

Day 91 of 100, closing the interop pillar. Navigation is the last thing most migrations touch, because it's the one piece every screen depends on.
The symptom
A half-migrated app with two back stacks:
// Legacy screens
findNavController().navigate(R.id.action_orders_to_detail)
// New Compose screens
composeNavController.navigate("detail/$id")
Two navigation controllers, each with its own history. Pressing back from a Compose screen reached from a fragment pops the Compose stack, finds it empty, and exits the app — skipping every fragment behind it.
Deep links reach one system or the other and can't cross. popBackStack to a screen in the
other system isn't expressible.
Why the obvious fix fails
The obvious fix is to coordinate them — track which system is active, forward back presses, synchronise the two histories.
That's a state machine over two stacks, and it has to handle every transition: fragment to compose, compose to fragment, deep link into either, process death restoring both. Each case is a special case, and the bugs are all in the transitions.
The other obvious approach — migrate all navigation at once — means every screen must be Compose before any of it works. For an app of any size that's a branch that lives for months.

The actual mechanism
androidx.navigation:navigation-compose and the fragment navigator can share a single
NavController. A composable destination and a fragment destination sit in one graph,
one back stack:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val navHostFragment = supportFragmentManager
.findFragmentById(R.id.nav_host) as NavHostFragment
navHostFragment.navController.graph = navHostFragment.createGraph(
startDestination = "orders",
) {
composable("orders") { OrderListScreen(onSelect = { … }) } // Compose
fragment<LegacyDetailFragment>("detail/{id}") // Fragment
composable("settings") { SettingsScreen() } // Compose
}
}
}
Back works. Deep links work. popBackStack("orders", inclusive = false) works across both
kinds of destination, because there is only one stack.
That single property is what makes an incremental migration viable: a screen can be
converted from fragment<…> to composable(…) in isolation, and nothing else in the graph
changes.
Type-safe routes
Current Navigation Compose supports serializable route types rather than strings, which removes the argument-parsing that string routes needed:
@Serializable data class OrderDetail(val id: String)
@Serializable data object Orders
NavHost(navController, startDestination = Orders) {
composable<Orders> { OrderListScreen(onSelect = { navController.navigate(OrderDetail(it)) }) }
composable<OrderDetail> { entry ->
val route: OrderDetail = entry.toRoute()
OrderDetailScreen(route.id)
}
}
The compiler now checks that a navigation call supplies the right arguments — a
navigate(OrderDetail()) with a missing id doesn't build. For a new graph this is the
form to use; for a migration, string routes interoperate with the fragment navigator more
straightforwardly, so mixing is common mid-flight.
What to migrate, in what order
Three rules that keep the intermediate states sane:
Move to a single NavController first, before converting any screen. If the app has
several controllers — one per activity, or a manual fragment-transaction system — unifying
them is a separate, self-contained change. Doing it at the same time as a screen conversion
confuses two failure modes.
Convert leaf destinations first. A screen nothing navigates through — a detail view, a settings page. Its conversion touches one graph entry.
Convert the host last. Moving from NavHostFragment in XML to a Compose NavHost is
the final step, and it's only safe once every destination is a composable.
The pieces that need attention
ViewModel scoping. A composable destination gets its own NavBackStackEntry as a
ViewModel store owner, so viewModel() inside it is scoped to that destination — which is
what you want, and different from a fragment's scoping in ways that matter for shared
ViewModels. For state shared across destinations, scope to the parent graph entry
explicitly:
val parentEntry = remember(entry) { navController.getBackStackEntry("checkout-graph") }
val sharedViewModel: CheckoutViewModel = viewModel(parentEntry)
Results. The fragment world's setFragmentResult has no Compose equivalent, and the
idiomatic replacement is the previous entry's SavedStateHandle:
navController.previousBackStackEntry
?.savedStateHandle
?.set("selected-address", addressId)
navController.popBackStack()
Better still, hoist the state so no result-passing is needed — a shared ViewModel at the graph level, which is Day 12's argument arriving in navigation.
Transitions. composable takes enterTransition/exitTransition per destination,
which replaces XML animation resources. Day 65's shared elements work across destinations
here, since each composable scope is an AnimatedVisibilityScope.
Deep links. deepLink = listOf(navDeepLink { uriPattern = "app://orders/{id}" }) on a
composable, and they resolve into the same graph as the fragment ones. Worth auditing during
the migration rather than after — a deep link pointing at a destination that changed kind
is a silent failure until a marketing campaign uses it.
What not to do
Don't pass the NavController down. Day 15's argument: it's a dependency, not an
ambient. Hoist a callback instead:
composable("orders") {
OrderListScreen(onSelect = { id -> navController.navigate("detail/$id") })
}
OrderListScreen is then previewable with {} and testable with a lambda. A screen taking
a NavController is neither.
Don't build a parallel navigation abstraction during the migration. A wrapper that "makes both systems look the same" is a third system to maintain and delete later. The exception is a thin sealed type describing destinations — that's data, not a navigation system, and it survives the migration intact.
How to prove it
The shared-back-stack property is the thing to verify, and it's one manual test:
Navigate fragment → compose → fragment → compose, then press back four times. You should land back where you started, in order, and the fifth press should exit. Anything else means two stacks.
Process death is the second: navigate three deep, background the app, kill the process from
the profiler, and reopen from Recents. The stack should restore — which it will, because
NavController saves it, and won't if you've bypassed it with manual transactions.
Deep links are the third, and they're the check most likely to find something:
adb shell am start -W -a android.intent.action.VIEW -d "app://orders/4711"
Fired at a Compose destination and at a fragment destination, both should land correctly with a sensible back stack behind them — not a single screen with nothing to go back to.
What this generalizes to
The interop pillar's conclusion: an incremental migration needs one thing that both sides agree on. Here it's the back stack; in Day 88 it was the theme; in Day 90 it was stable identity. Wherever the two frameworks share a single source of truth, screens can move one at a time. Wherever they don't, you get two of something and a synchronisation problem.
That's the question worth asking before any framework migration: what is the shared spine, and does the new framework plug into it? If there is one, the migration is a sequence of small changes. If there isn't, it's a rewrite regardless of how it's scheduled.
Four days of interop reduce to that, plus its corollary: the mechanics are always easier
than they look, and the sequencing is always harder. ComposeView is one class,
AndroidView is three lambdas, a LazyColumn port is a lambda — and the decisions about
which seam, which order, and what stays shared are what determine whether the migration
finishes.
Tomorrow, Day 92 opens the tools pillar.
Day 91 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Navigation interop.