Jetpack Compose Navigation in Large Apps: From 600 Line NavHost to Clean Architecture
TL;DR
- A monolithic
NavHostwith 40+ destinations becomes unreadable fast and signals a deeper architecture problem - Nested navigation graphs are the first step — they group related destinations and isolate their
ViewModelscope - Feature module NavGraph extensions let each module own its own routes without importing the world
- Type-safe navigation (Navigation Compose 2.8+) eliminates stringly-typed routes and catches bugs at compile time
- Navigation 3 is coming — you don’t need to wait for it, but you should design with its patterns in mind
A thread on r/androiddev hit close to home recently. The developer had a single NavHost with 40+ composable destinations, 12 hiltViewModel() declarations scattered at the top level, and a file pushing 600 lines. Their question: “How do you structure Jetpack Compose navigation architecture in large apps?”
The 13-comment discussion that followed covered every pattern worth knowing — and a few traps worth avoiding. This post pulls those together into a practical guide you can apply to an existing app.
Why Your NavHost Becomes Unmanageable
The problem isn’t that you wrote bad code. It’s that Navigation Compose’s default mental model — one NavHost, all destinations — scales fine for demo apps and falls apart somewhere around destination 20.
Here’s what a typical growing app looks like:
// The monolith — what happens when you don't plan
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController = navController, startDestination = "home") {
composable("home") {
val vm: HomeViewModel = hiltViewModel()
HomeScreen(vm)
}
composable("feed") {
val vm: FeedViewModel = hiltViewModel()
FeedScreen(vm)
}
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: ""
val vm: ProfileViewModel = hiltViewModel()
ProfileScreen(userId, vm)
}
composable("settings") {
val vm: SettingsViewModel = hiltViewModel()
SettingsScreen(vm)
}
composable("notifications") {
val vm: NotificationsViewModel = hiltViewModel()
NotificationsScreen(vm)
}
// ... 35 more destinations
// ... 7 more hiltViewModel() declarations
// ... arguments, deep links, transitions mixed together
}
}This file becomes your app’s nervous system. Every developer touches it. It accumulates ViewModel declarations, magic strings, argument parsing, deep link patterns, and transition specs — all interleaved. After 20+ routes, as one developer in the thread put it, it’s “practically unreadable and a merge conflict waiting to happen.”
The fix isn’t one thing. It’s a layered approach applied in order.
Step 1: Group Routes with Nested Navigation Graphs
The quickest win is navigation() blocks — Compose Navigation’s built-in way to group related destinations into a nested graph.
// After: grouped with nested graphs
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController = navController, startDestination = "home") {
composable("home") {
val vm: HomeViewModel = hiltViewModel()
HomeScreen(vm)
}
// Profile feature graph
navigation(startDestination = "profile/me", route = "profile_graph") {
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: ""
val vm: ProfileViewModel = hiltViewModel()
ProfileScreen(userId, vm)
}
composable("profile/edit") {
val vm: EditProfileViewModel = hiltViewModel()
EditProfileScreen(vm)
}
composable("profile/followers") {
val vm: FollowersViewModel = hiltViewModel()
FollowersScreen(vm)
}
}
// Settings feature graph
navigation(startDestination = "settings/main", route = "settings_graph") {
composable("settings/main") {
val vm: SettingsViewModel = hiltViewModel()
SettingsScreen(vm)
}
composable("settings/notifications") {
val vm: NotificationsViewModel = hiltViewModel()
NotificationSettingsScreen(vm)
}
composable("settings/privacy") {
val vm: PrivacyViewModel = hiltViewModel()
PrivacyScreen(vm)
}
}
}
}Nested graphs do two useful things beyond organization:
- Scoped ViewModels — a
ViewModelscoped to a graph survives within that graph’s back stack but clears when you exit the graph entirely. This is the right scope for “coordinator” ViewModels that manage state across a feature’s screens. - Navigation encapsulation — the parent NavHost only needs to know each graph’s route, not every leaf destination inside it.
You can navigate to a specific destination inside a nested graph with the usual navController.navigate("profile/edit"), or to the graph’s start destination with navController.navigate("profile_graph"). Both work.
Step 2: Extract NavGraph Extensions Per Feature
Nested graphs inside one file still scale poorly. The next step is moving each graph into an extension function on NavGraphBuilder, ideally living in its own file or module.
// profile/ProfileNavGraph.kt
fun NavGraphBuilder.profileNavGraph(navController: NavController) {
navigation(startDestination = "profile/me", route = "profile_graph") {
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: ""
val vm: ProfileViewModel = hiltViewModel()
ProfileScreen(
userId = userId,
onEditClick = { navController.navigate("profile/edit") },
onFollowersClick = { navController.navigate("profile/followers") }
)
}
composable("profile/edit") {
val vm: EditProfileViewModel = hiltViewModel()
EditProfileScreen(
vm = vm,
onBack = { navController.popBackStack() }
)
}
composable("profile/followers") {
val vm: FollowersViewModel = hiltViewModel()
FollowersScreen(vm)
}
}
}// settings/SettingsNavGraph.kt
fun NavGraphBuilder.settingsNavGraph(navController: NavController) {
navigation(startDestination = "settings/main", route = "settings_graph") {
composable("settings/main") {
val vm: SettingsViewModel = hiltViewModel()
SettingsScreen(
vm = vm,
onNotificationsClick = { navController.navigate("settings/notifications") },
onPrivacyClick = { navController.navigate("settings/privacy") }
)
}
composable("settings/notifications") {
val vm: NotificationsViewModel = hiltViewModel()
NotificationSettingsScreen(vm)
}
composable("settings/privacy") {
val vm: PrivacyViewModel = hiltViewModel()
PrivacyScreen(vm)
}
}
}The root NavHost becomes a clean assembly point:
// AppNavHost.kt — now under 30 lines
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController = navController, startDestination = "home") {
composable("home") {
val vm: HomeViewModel = hiltViewModel()
HomeScreen(vm)
}
profileNavGraph(navController)
settingsNavGraph(navController)
feedNavGraph(navController)
onboardingNavGraph(navController)
}
}Each feature team owns its NavGraph extension. The root file only imports — it doesn’t define. Merge conflicts drop dramatically.
One developer in the thread recommended this exact split: “put each feature’s NavGraph extension in the feature module itself, then the app module just calls them.” That’s the pattern that scales to 40+ destinations without breaking a sweat.
Step 3: Go Multi-Module — Each Feature Declares Its Own Graph
If you’re in a multi-module project (or planning to move there), the natural next step is having each feature module expose its NavGraph extension as part of its public API.
// :feature:profile module — ProfileNavigation.kt
// Public API: this is the only navigation-related thing :app needs to import
object ProfileRoutes {
const val GRAPH = "profile_graph"
const val PROFILE = "profile/{userId}"
const val EDIT = "profile/edit"
const val FOLLOWERS = "profile/followers"
fun profile(userId: String) = "profile/$userId"
}
fun NavGraphBuilder.profileNavGraph(
navController: NavController,
onNavigateToSettings: () -> Unit // Cross-module navigation via callback
) {
navigation(
startDestination = ProfileRoutes.PROFILE,
route = ProfileRoutes.GRAPH
) {
composable(ProfileRoutes.PROFILE) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: ""
val vm: ProfileViewModel = hiltViewModel()
ProfileScreen(
userId = userId,
onEditClick = { navController.navigate(ProfileRoutes.EDIT) },
onFollowersClick = { navController.navigate(ProfileRoutes.FOLLOWERS) },
onSettingsClick = onNavigateToSettings // Cross-module — caller provides this
)
}
composable(ProfileRoutes.EDIT) {
EditProfileScreen(hiltViewModel()) { navController.popBackStack() }
}
composable(ProfileRoutes.FOLLOWERS) {
FollowersScreen(hiltViewModel())
}
}
}The key constraint: feature modules should never import each other. Cross-module navigation happens via callbacks passed in from the :app module — the only layer that sees everything.
// :app module — AppNavHost.kt
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeScreen(hiltViewModel()) }
profileNavGraph(
navController = navController,
onNavigateToSettings = { navController.navigate(SettingsRoutes.GRAPH) }
)
settingsNavGraph(navController)
}
}This pattern also solves the deep links across module boundaries concern raised in the thread. Deep links are registered in each feature’s composable() block — the feature owns them. The app module just assembles the graph.
Step 4: Add Type-Safe Navigation (Navigation Compose 2.8+)
All the patterns above still use string routes. String-based navigation has real costs: typos compile fine and blow up at runtime, argument types aren’t enforced, and refactoring a route name requires grepping rather than “Find Usages.”
Navigation Compose 2.8 introduced type-safe navigation using Kotlin serialization. It’s the right default for new routes and worth migrating to incrementally.
// Add to build.gradle.kts
dependencies {
implementation("androidx.navigation:navigation-compose:2.8.9")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
plugins {
kotlin("plugin.serialization")
}Define routes as serializable objects or data classes:
// Type-safe route definitions
@Serializable
object HomeRoute
@Serializable
data class ProfileRoute(val userId: String)
@Serializable
object EditProfileRoute
@Serializable
object SettingsRouteThe NavHost and composables change to use these types directly:
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController = navController, startDestination = HomeRoute) {
composable<HomeRoute> {
HomeScreen(hiltViewModel())
}
composable<ProfileRoute> { backStackEntry ->
val route: ProfileRoute = backStackEntry.toRoute()
ProfileScreen(
userId = route.userId,
onEditClick = { navController.navigate(EditProfileRoute) }
)
}
composable<EditProfileRoute> {
EditProfileScreen(hiltViewModel()) { navController.popBackStack() }
}
composable<SettingsRoute> {
SettingsScreen(hiltViewModel())
}
}
}Navigation calls become type-checked:
// Compile-time safe — the compiler validates this
navController.navigate(ProfileRoute(userId = "user123"))
// String-based equivalent — a typo here is a runtime crash
navController.navigate("profile/user123")Arguments are now just constructor parameters on your route class. No argument bundle parsing. No backStackEntry.arguments?.getString("userId"). The type system does the work.
The official type-safe navigation guide covers the full migration path, including deep links with type-safe routes.
Handling Deep Links Across Modules
Deep links are where multi-module navigation gets genuinely tricky. The thread flagged this concern directly, and it deserves a dedicated answer.
Each composable destination can declare its own deep links:
composable<ProfileRoute>(
deepLinks = listOf(
navDeepLink<ProfileRoute>(basePath = "https://yourapp.com/profile")
)
) { backStackEntry ->
val route: ProfileRoute = backStackEntry.toRoute()
ProfileScreen(userId = route.userId)
}For this to work across modules:
- Each feature module registers its deep links inside its own
NavGraphBuilderextension - The app manifest declares
<intent-filter>entries for each deep link domain — this stays in:app’sAndroidManifest.xml - The
:appmodule assembles all NavGraphs, which assembles all deep link registrations
The separation is clean: features own what URLs they handle, the app module owns that the app responds to those URLs at the manifest level.
One important caveat: if two feature modules try to claim the same URL pattern, you’ll get a runtime conflict that’s hard to debug. Establish a URL naming convention per module early — for example, https://yourapp.com/profile/* is exclusively owned by :feature:profile.
What About Navigation 3?
Navigation 3 is a new, lower-level navigation API that Google is developing as a future replacement for Navigation Compose. It gives you direct control over the back stack as a List<T>, which makes complex multi-pane and adaptive layouts far easier to build.
It’s not stable yet. You don’t need to wait for it.
But the patterns here — type-safe route objects, feature-scoped graphs, callback-based cross-module navigation — align well with what Navigation 3 expects. If you build this way today, migrating later will be incremental rather than a rewrite.
Check the Navigation 3 experimental documentation for its current status. It moves fast.
Practical Migration Checklist
If you’re staring at a 600-line NavHost right now, here’s how to attack it without stopping feature development:
- Audit first. List all destinations and group them by feature domain. Don’t start moving code until the groupings feel right.
- Extract
NavGraphBuilderextensions one feature at a time. Start with the largest group, not the most complex one. Quick wins matter for momentum. - Add type-safe routes incrementally. You can mix string and type-safe destinations in the same
NavHostduring migration. Don’t feel pressured to convert everything at once. - Establish cross-module navigation conventions before splitting modules. Callbacks are simpler than a shared navigation module, but document the pattern so every team follows it.
- Audit deep links after each extraction. Test each deep link manually after moving its host composable to a new graph extension.
- Delete the route constants files last. Only remove old string constants once every caller is migrated to type-safe equivalents.
The thread consensus was clear: the right time to restructure navigation is before it’s completely unmanageable, not after. If you’re at 20 destinations and starting to feel the friction, act now. At 40+ with 12 hiltViewModel() calls in one file, you’re already past where this should have been caught.
FAQ
How do you structure navigation in a large Compose app?
The recommended approach is layered: start with navigation() blocks to group related destinations into nested graphs, extract each group into a NavGraphBuilder extension function (one per feature), then move each extension into its own feature module. Add type-safe route definitions with Navigation Compose 2.8+ to eliminate stringly-typed routes. The root NavHost becomes a thin assembly point that calls each feature’s extension function — typically under 30 lines.
What is the difference between NavHost and a nested NavGraph?
NavHost is the root container that owns the NavController and renders the current destination. A nested NavGraph (created with the navigation() builder inside NavHost) is a sub-graph of destinations grouped under a single route. Nested graphs enable ViewModel scoping to a sub-tree — a ViewModel scoped to a graph lives as long as you’re inside that graph and clears when you exit it. NavHost has exactly one; your app can have as many nested graphs as you need.
How do I use Navigation Compose in a multi-module project?
Each feature module exposes a NavGraphBuilder extension function as its public navigation API. The extension registers the feature’s destinations, handles internal navigation with the NavController, and accepts callbacks for cross-module navigation (e.g., onNavigateToSettings: () -> Unit). The :app module assembles all extensions into a single NavHost. Feature modules never import each other — all cross-module navigation passes through the :app layer via callbacks or shared route constants.
Why does NavHost become unreadable after 20+ routes?
A single NavHost centralizes destination registration, argument parsing, ViewModel instantiation, deep link patterns, and enter/exit transitions for every screen in the app. Each new route adds at minimum 5-10 lines of interleaved concerns. At 20 routes you have 100-200 lines of tightly coupled code that every developer modifies, creating consistent merge conflicts and making it hard to trace any individual route’s full configuration.
Is type-safe navigation worth migrating to?
Yes, especially for apps with complex argument types or frequent refactoring. String-based routes fail at runtime; type-safe routes defined as @Serializable data classes fail at compile time. The migration is incremental — you can mix both approaches in the same NavHost during transition. The main investment is defining route classes and updating navigate() call sites. For apps already using Navigation Compose 2.8+, the library cost is zero; you just need the Kotlin serialization plugin.
Should I wait for Navigation 3 before restructuring?
No. Navigation 3 is experimental and its timeline is uncertain. More importantly, the architectural patterns that scale today — type-safe routes, feature-scoped NavGraph extensions, callback-based cross-module navigation — are directionally aligned with Navigation 3’s design. Restructuring now reduces technical debt and positions you for an incremental migration when Navigation 3 stabilizes, rather than a ground-up rewrite on top of an already painful monolith.
Source: r/androiddev thread on NavHost scale
Building an Android app and want to track how architecture improvements affect your Play Store ratings? ExtensionBooster gives you the review monitoring and analytics to correlate technical changes with real user sentiment.
Share this article
Build better extensions with free tools
Icon generator, MV3 converter, review exporter, and more — no signup needed.
Related Articles
AI Tools for Android Development: What Works, What Breaks, and What to Skip
Honest look at AI coding tools for Android in 2026. Where Claude Code and Cursor excel, where they fail, and how to avoid shipping broken code.
Android Adaptive Layouts: Your App on Foldables, Tablets, and Everything in Between
300M+ large-screen Android devices. Android 17 mandates adaptive support. Window size classes, canonical layouts, and foldable posture handling.
Android App Marketing & ASO: The Full-Funnel Google Play Strategy for 2026
Google Play now ranks retention over downloads. The 2026 Android ASO framework — keyword research to Day-7 retention loops that compound growth.