Navigation in Android: Fragment Nav vs Compose Nav vs Deep Links
Understand the full navigation stack — back stack management, type-safe routes, deep links, and nested graphs
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Android Navigation manages how users move between screens. Fragment Navigation Component uses XML nav graphs and Safe Args. Compose Navigation uses NavController + NavHost with string-based or type-safe routes. Deep links map external URLs or custom scheme URIs to specific in-app destinations — App Links (HTTPS + verification) are preferred over custom schemes. Both systems handle back stack management, nested graphs, and argument passing.
Real-world relevance
A field operations enterprise app uses a nested nav graph for the 'Work Order' flow (list → detail → edit → signature capture) scoped to a shared WorkOrderViewModel. Deep links from push notifications open directly to WorkOrderDetailScreen via an App Link (https://fieldops.com/orders/{id}). Navigating from the ViewModel uses a Channel to avoid holding a NavController reference. The auth flow is a separate nested graph popped entirely on login success using popUpTo('auth') { inclusive = true }.
Key points
- Fragment Navigation Component overview — The Navigation Component (nav graph XML + NavController + NavHostFragment) was introduced to standardize fragment transactions. A nav_graph.xml declares all destinations (fragments) and actions (transitions). NavController.navigate(R.id.action_to_detail) performs the transaction. Handles back stack, deep links, and safe args code generation.
- Safe Args plugin (Fragment Nav) — Safe Args generates type-safe Kotlin classes for navigation arguments, eliminating bundle key string errors. Navigate with DetailFragmentDirections.actionToDetail(productId = 42). Receive with val args: DetailFragmentArgs by navArgs(). Available for Fragment Nav only — Compose Nav has its own type-safety mechanism.
- Compose Navigation setup — val navController = rememberNavController(); NavHost(navController, startDestination = 'home') { composable('home') { HomeScreen(navController) }; composable('detail/{id}') { DetailScreen() } }. Route strings are matched like URLs. Arguments are extracted from BackStackEntry.arguments.
- Type-safe Compose Nav (Navigation 2.8+) — With Navigation 2.8+ and @Serializable data class/object routes, you replace string routes with type-safe objects: NavHost(startDestination = Home) { composable { HomeScreen() }; composable { entry -> val args: Detail = entry.toRoute() } }. Eliminates string-based route bugs and enables IDE refactoring.
- Passing arguments in Compose Nav — String-based: 'detail/{productId}' with navArgument('productId') { type = NavType.IntType }. Type-safe: @Serializable data class Detail(val productId: Int). Both approaches inject arguments into the composable via the BackStackEntry. Avoid passing large objects — pass IDs and load in the destination ViewModel.
- Deep links — App Links vs Custom Schemes — App Links use HTTPS URLs (https://app.fieldops.com/order/123) verified by Google via Digital Asset Links JSON on the server. They open your app directly (no disambiguation dialog). Custom schemes (fieldops://order/123) are simpler to set up but show a disambiguation dialog and can be hijacked by other apps. Prefer App Links for production.
- Deep links in Compose Nav — Add deepLinks = listOf(navDeepLink { uriPattern = 'https://app.fieldops.com/order/{orderId}' }) to the composable() block. The NavController handles the intent automatically. Declare the intent-filter in AndroidManifest with android:autoVerify='true' for App Links.
- Nested navigation graphs — Group related destinations into nested NavGraphs: navigation(startDestination = 'login', route = 'auth') { composable('login') {...}; composable('register') {...} }. Navigate to the graph: navController.navigate('auth'). Back stack pops to the parent graph's start destination. Ideal for feature-module navigation.
- Back stack management — navController.navigate('detail') pushes to the stack. navController.popBackStack() pops one. navigate with popUpTo('home') { inclusive = false } pops up to (but not including) 'home' before pushing — prevents large stacks. Single-top: launchSingleTop = true prevents duplicate destinations.
- Navigating from ViewModel (event channel) — ViewModels should not hold a NavController reference (memory leak). Instead, emit navigation events via a Channel and collect them in the composable inside a LaunchedEffect, then call navController.navigate() in the composable. This keeps ViewModel testable and lifecycle-safe.
- Shared ViewModel across destinations — val viewModel: CheckoutViewModel = hiltViewModel(navController.getBackStackEntry('checkout_graph')) — scopes the ViewModel to a nested graph's lifetime. All destinations in the graph share the same ViewModel instance. Cleaned up when the graph is popped. The correct pattern for multi-step flows.
- Fragment Nav vs Compose Nav — when to use each — Fragment Nav: existing Fragment-based apps, shared element transitions (fully supported), complex animations with Fragment API. Compose Nav: pure Compose screens, type-safe routes, simpler code. Hybrid: NavHostFragment wrapping a ComposeView — a migration pattern. In 2026+, new apps should use Compose Nav with typed routes.
Code example
// ===== COMPOSE NAVIGATION — TYPE-SAFE ROUTES (Nav 2.8+) =====
// Route definitions
@Serializable object Home
@Serializable object WorkOrderList
@Serializable data class WorkOrderDetail(val orderId: String)
@Serializable data class WorkOrderEdit(val orderId: String)
// App-level NavHost
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController = navController, startDestination = Home) {
composable<Home> { HomeScreen(navController) }
// Nested graph — shared WorkOrderViewModel scoped to this graph
navigation<WorkOrderGraph>(startDestination = WorkOrderList) {
composable<WorkOrderList> {
WorkOrderListScreen(navController)
}
composable<WorkOrderDetail>(
deepLinks = listOf(navDeepLink<WorkOrderDetail>(
basePath = "https://fieldops.com/orders"
))
) { entry ->
val route: WorkOrderDetail = entry.toRoute()
WorkOrderDetailScreen(orderId = route.orderId, navController = navController)
}
composable<WorkOrderEdit> { entry ->
val route: WorkOrderEdit = entry.toRoute()
WorkOrderEditScreen(orderId = route.orderId)
}
}
}
}
// Navigate to detail
navController.navigate(WorkOrderDetail(orderId = "WO-001"))
// Navigate with back stack management — clear auth graph on login
navController.navigate(Home) {
popUpTo<AuthGraph> { inclusive = true }
launchSingleTop = true
}
// ===== ViewModel navigation events =====
class WorkOrderViewModel : ViewModel() {
private val _navEvents = Channel<WorkOrderNavEvent>(Channel.BUFFERED)
val navEvents = _navEvents.receiveAsFlow()
fun onEditTapped(orderId: String) {
viewModelScope.launch { _navEvents.send(WorkOrderNavEvent.NavigateToEdit(orderId)) }
}
}
sealed class WorkOrderNavEvent {
data class NavigateToEdit(val orderId: String) : WorkOrderNavEvent()
object NavigateBack : WorkOrderNavEvent()
}
// Collect in composable
@Composable
fun WorkOrderDetailScreen(orderId: String, navController: NavController) {
val viewModel: WorkOrderViewModel = hiltViewModel()
LaunchedEffect(Unit) {
viewModel.navEvents.collect { event ->
when (event) {
is WorkOrderNavEvent.NavigateToEdit ->
navController.navigate(WorkOrderEdit(event.orderId))
WorkOrderNavEvent.NavigateBack -> navController.popBackStack()
}
}
}
}
// ===== SHARED VIEWMODEL scoped to nested graph =====
@Composable
fun WorkOrderEditScreen(orderId: String) {
// ViewModel lives as long as the WorkOrderGraph is on the back stack
val sharedViewModel: WorkOrderViewModel =
hiltViewModel(LocalNavBackStackEntry.current.let { entry ->
val graph = entry.destination.parent!!
rememberNavController().getBackStackEntry(graph.id)
})
}
// ===== ANDROIDMANIFEST for App Links =====
// <intent-filter android:autoVerify="true">
// <action android:name="android.intent.action.VIEW"/>
// <category android:name="android.intent.category.DEFAULT"/>
// <category android:name="android.intent.category.BROWSABLE"/>
// <data android:scheme="https" android:host="fieldops.com" android:pathPrefix="/orders"/>
// </intent-filter>Line-by-line walkthrough
- 1. Route definitions as @Serializable objects/data classes replace 'home', 'detail/{id}' strings — the serialization library handles URL encoding/decoding of parameters
- 2. NavHost(startDestination = Home) uses the object type as the destination, not a string — type-checked at compile time
- 3. navigation(startDestination = WorkOrderList) creates a nested graph; all composables inside share back stack context with the graph
- 4. composable(deepLinks = listOf(navDeepLink(basePath = ...))) registers the App Link for this destination — basePath + serialized route fields form the full URL
- 5. entry.toRoute() deserializes the route arguments back into the data class — type-safe access to orderId
- 6. navController.navigate(Home) { popUpTo { inclusive = true } } removes the entire auth graph from the back stack on successful login — prevents back navigation to login
- 7. Channel(BUFFERED) in the ViewModel buffers events if the composable is not yet collecting — prevents dropped events during recomposition
- 8. LaunchedEffect(Unit) in the composable collects the channel flow — receives navigation events and calls navController.navigate() from the composable layer where NavController lives
- 9. hiltViewModel(backStackEntry) scopes the ViewModel to the graph's NavBackStackEntry — the same instance is returned for all composables in the graph
- 10. android:autoVerify='true' in the manifest intent-filter triggers OS verification of the assetlinks.json — required for App Links to open without disambiguation dialog
Spot the bug
class OrderViewModel @Inject constructor(
private val repo: OrderRepository,
private val navController: NavController // Bug 1
) : ViewModel() {
fun onOrderTapped(orderId: String) {
navController.navigate("order_detail/$orderId") // Bug 2
}
}
@Composable
fun OrderListScreen() {
val navController = rememberNavController() // Bug 3
val viewModel: OrderViewModel = hiltViewModel()
LazyColumn {
items(viewModel.orders, key = { it.id }) { order ->
OrderRow(order = order, onClick = { viewModel.onOrderTapped(order.id) })
}
}
}
// NavHost somewhere in app root
NavHost(navController = appNavController, startDestination = "orders") {
composable("orders") { OrderListScreen() }
composable("order_detail/{orderId}") { entry ->
val orderId = entry.arguments?.getString("orderId") // Bug 4
OrderDetailScreen(orderId = orderId!!)
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Navigation with Compose — Official Guide (Android Developers)
- Type-safe Navigation in Compose (Nav 2.8+) (Android Developers)
- Android App Links — Verification Guide (Android Developers)
- Nested Navigation Graphs in Compose (Android Developers Medium)
- Navigate to a graph-scoped ViewModel (Android Developers)