Scale Compose Navigation Beyond Toy Apps
Centralize routes in sealed classes with helper functions, pass nav callbacks to screens, and use popUpTo(inclusive=true), launchSingleTop=true, restoreState=true for clean back stacks in auth flows, bottom tabs, nested graphs, and deep links.
Centralize Routes and Decouple Screens for Maintainability
Treat navigation as graph movement where routes are structured strings defined in a single sealed class like sealed class AppScreen(val route: String), with objects for simple screens (Home : AppScreen("home")) and data classes for args (UserDetails : AppScreen("user/{userId}") { fun createRoute(userId: Int) = "user/$userId" }). This prevents scattered strings, eases renames, and keeps code refactor-safe.
In NavHost, map routes to composables with composable(route, arguments = listOf(navArgument("userId") { type = NavType.IntType })) { backStackEntry -> val userId = backStackEntry.arguments?.getInt("userId") }. Screens receive typed callbacks like onOpenUser: (Int) -> Unit instead of direct NavController access, making UI previewable, testable, and focused on rendering—not coordination. This scales to 15+ screens by keeping NavHost as the connection source of truth.
Master Back Stack with Nav Options for Real Flows
Forward nav is simple (navController.navigate(route)), but control back stack to avoid ghosts: after login, navigate("home") { popUpTo("login") { inclusive = true } } removes login entirely. For auth flows, set dynamic startDestination based on isLoggedIn state.
Prevent duplicates on retaps with navigate(route) { launchSingleTop = true }. In bottom nav, combine options in tab clicks: navigate(item.route) { popUpTo(navController.graph.startDestinationId) { saveState = true }; launchSingleTop = true; restoreState = true }. This pops to root on switches (no growing stack), saves/restores tab state, and skips duplicates—making tabs feel like independent sections with expected back behavior and polished UX.
Group Flows with Nested Graphs and Handle Deep Links
For related screens (login/signup/forgot-password), use nested navigation(route = "auth_graph", startDestination = "login") { composable(...) } inside root NavHost (route = "root_graph", startDestination = "auth_graph"). Exit flows cleanly: navigate("main_graph") { popUpTo("auth_graph") { inclusive = true } }. This mirrors product structure (auth vs. main), simplifies complex graphs, and clarifies transitions.
Deep links align with routes: composable(..., deepLinks = listOf(navDeepLink { uriPattern = "myapp://article/{articleId}" })). A link like myapp://article/abc123 lands directly, pulling articleId from args. Centralize creation helpers ensure patterns match, avoiding maintenance pain. Avoid passing full objects—stick to primitives/typed args for reliability over serialization hacks.