Scale Compose Nav: Sealed Routes to Deep Links

Centralize routes in sealed classes, pass nav callbacks to screens, and use popUpTo/launchSingleTop for back stack control—patterns that prevent mess in real apps with auth, tabs, and flows.

Build Predictable Navigation with Sealed Routes and Callbacks

Treat navigation as graph movement where routes are centralized in a sealed class like sealed class AppScreen(val route: String), defining objects such as data object Home : AppScreen("home") and data object UserDetails : AppScreen("user/{userId}") { fun createRoute(userId: Int): String = "user/$userId" }. This keeps route strings in one place, enables safe refactoring, and avoids ad-hoc string building like "user/${id}" scattered across files, which invites bugs.

In NavHost, map routes to composables: composable(AppScreen.Home.route) { HomeScreen(onOpenUser = { userId -> navController.navigate(AppScreen.UserDetails.createRoute(userId)) }) }. Screens receive callbacks like (Int) -> Unit instead of direct NavController access, making UI previewable, testable, and focused on rendering—not coordination. For arguments, use navArgument("userId") { type = NavType.IntType } and extract via backStackEntry.arguments?.getInt("userId"), ensuring typed navigation without string parsing chaos.

This structure scales: NavHost is the connection source of truth, mimicking typed routing benefits without native support.

Master Back Stack with Nav Options for Real Flows

Control history explicitly to match product needs. After login, navController.navigate(AppScreen.Home.route) { popUpTo(AppScreen.Login.route) { inclusive = true } } removes login entirely—preventing back navigation to auth screens. Conditional starts like val startDestination = if (isLoggedIn) AppScreen.Home.route else AppScreen.Login.route handle auth state.

Prevent duplicates on re-taps with launchSingleTop = true in navigate() calls. For programmatic flows post-API, combine: popUpTo clears unwanted history, inclusive = true prunes fully, avoiding ghost screens.

Auth example: Nested LoginScreen(onLoginSuccess = { ... }) triggers stack-clearing nav, landing users in main app cleanly.

Polish Bottom Nav and Nested Graphs for App-Like Feel

Bottom tabs expect independent sections with state restoration. Use Scaffold with BottomBar: track currentDestination via navController.currentBackStackEntryAsState(), select via hierarchy.any { it.route == item.route }. On tab click: navigate(item.route) { popUpTo(navController.graph.startDestinationId) { saveState = true }; launchSingleTop = true; restoreState = true }. This caps stack growth, saves/restores tab state, and skips duplicates—making switches feel native.

Group related screens in nested graphs: NavHost(route = "root_graph", startDestination = "auth_graph") { navigation(route = "auth_graph", startDestination = "login") { composable("login") { ... } } }. Exit auth: navigate("main_graph") { popUpTo("auth_graph") { inclusive = true } }. Graphs mirror product structure (auth vs. main), simplifying complex apps with 15+ screens.

Deep links are routes with URIs: for ArticleDetails : AppScreen("article/{articleId}"), add deepLinks = listOf(navDeepLink { uriPattern = "myapp://article/{articleId}" }) alongside navArgument("articleId") { type = NavType.StringType }. myapp://article/abc123 opens directly, extracting articleId via backstack—handling notifications or marketing links without custom parsing.

Summarized by x-ai/grok-4.1-fast via openrouter

8253 input / 1740 output tokens in 18352ms

© 2026 Edge