
A while back, I wrote an article on Jetpack Compose navigation where I walked through routes, arguments, nested graphs, and real-world flows. That article came from a very real place, just like my earlier SwiftUI pieces. I had revisited older code, noticed patterns that didn’t scale well, and wanted to document what finally clicked for me.
That article is still useful. In fact, I think it works well as a beginner-friendly guide or for apps with a handful of screens. If you have a home screen, a settings screen, maybe a details page, the approach there is more than enough.
But after working on larger apps, I realized something important.
That approach is not enough once your app grows.
It does not fully answer how to structure navigation when you have 20, 30, or 50 screens. It also does not address how navigation interacts with state, which is where most real-world complexity actually comes from.
So this article is the follow-up I wish I had written earlier.
This is not about learning the API. It is about how to structure navigation so it does not fall apart as your app grows.
When navigation stops being simple
Navigation looks easy in demos.
You create a NavHost, define a few composable destinations, and call navigate(). Everything works. You feel like you understand it.
Then the app starts becoming real.
You add login flows. You add onboarding. You introduce bottom tabs. You need to navigate after an API call succeeds. You need to clear the back stack after authentication. You need deep links. You have multiple features linking to each other.
And suddenly, navigation is not just moving between screens anymore. It becomes architecture.
That is the point where most apps start getting messy.
The mental model still holds
Even for large apps, the core mental model does not change.
- A route identifies a destination
- NavHost defines the map
- NavController moves through that map
What changes is how you organize that map.
In small apps, a single NavHost with a few routes is fine.
In larger apps, that same approach turns into a giant file that is hard to reason about, hard to maintain, and easy to break.
The mistake I made early on
The biggest mistake I made when I first used Compose Navigation in a real project was this:
I kept everything in one place.
One NavHost. Dozens of composable() blocks. Routes defined inline. Navigation logic scattered across screens.
It worked, but it did not scale.
Over time:
- The file became too large to navigate mentally
- Different flows started interfering with each other
- Back stack behavior became harder to reason about
- Refactoring became risky
The fix was not learning new APIs. The fix was changing how I structured navigation.
How to structure navigation in a real app
If your app has many screens, the goal is not to centralize everything into one file.
The goal is to centralize ownership, while splitting structure.
A pattern that has worked well for me looks like this:
- One root NavHost
- Separate navigation graphs per feature or flow
- Centralized route definitions
- Navigation handled at the route or container level
In practice, that looks like this.
Root navigation
@Composable
fun App() {
val navController = rememberNavController()
AppNavHost(navController)
}
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = Graph.AUTH,
route = Graph.ROOT
) {
authNavGraph(navController)
mainNavGraph(navController)
}
}
The root graph only answers one question:
What are the major flows in the app?
Not every screen.
Feature graphs
Each flow gets its own navigation graph.
For example, authentication:
fun NavGraphBuilder.authNavGraph(navController: NavHostController) {
navigation(
startDestination = "login",
route = Graph.AUTH
) {
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate(Graph.MAIN) {
popUpTo(Graph.AUTH) { inclusive = true }
}
}
)
}
composable("signup") {
SignupScreen()
}
}
}And your main app flow:
fun NavGraphBuilder.mainNavGraph(navController: NavHostController) {
navigation(
startDestination = "home",
route = Graph.MAIN
) {
composable("home") {
HomeRoute(navController)
}
composable("settings") {
SettingsScreen()
}
}
}This keeps related screens together and prevents your navigation from becoming one giant flat graph.
Routes should still be structured
Even in large apps, the rule from the previous article still applies.
Do not scatter route strings everywhere.
Use helpers.
sealed class AppScreen(val route: String) {
data object Home : AppScreen("home")
data object UserDetails : AppScreen("user/{userId}") {
fun createRoute(userId: Long) = "user/$userId"
}
}This small habit saves you from a surprising number of bugs later.
Where state actually lives (this is the missing piece)
This is the part I did not cover well in my previous article. Navigation does not own your data. It only moves you between screens. In real apps, state should live in one of three places.
1. Navigation arguments
Only pass small, stable values:
- IDs n- filters
- simple flags
Example:
navController.navigate(UserDetails.createRoute(userId))
2. ViewModel (screen state)
Each screen should have a ViewModel that loads and holds business data.
class UserDetailsViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val userId: Long =
checkNotNull(savedStateHandle["userId"])
val uiState = MutableStateFlow(UserDetailsUiState())
}
This is where:
- API calls happen
- loading and error states live
- business logic is handled
3. Composable state
UI-only state stays in the composable.
var text by rememberSaveable { mutableStateOf("") }This includes:
- text fields
- toggles
- temporary UI selections
Why this separation matters
If you try to push state through navigation, things break quickly.
- Objects become hard to serialize
- Data becomes stale
- Navigation becomes fragile
Passing IDs and loading data in the destination keeps things predictable.
Linking many screens cleanly
When multiple screens link to each other, the temptation is to call NavController everywhere.
That works, but it spreads navigation logic across the UI.
A cleaner approach is to keep navigation at the route level.
@Composable
fun HomeRoute(navController: NavHostController) {
HomeScreen(
onOpenSettings = {
navController.navigate("settings")
},
onOpenUser = { id ->
navController.navigate("user/$id")
}
)
}
@Composable
fun HomeScreen(
onOpenSettings: () -> Unit,
onOpenUser: (Long) -> Unit
) {
Column {
Button(onClick = onOpenSettings) {
Text("Settings")
}
Button(onClick = { onOpenUser(42) }) {
Text("Open User")
}
}
}
Now your UI stays reusable and easier to test.
Bottom navigation and multiple flows
For apps with tabs, navigation needs to handle multiple sections cleanly.
A common pattern is:
navController.navigate(route) {
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
launchSingleTop = true
restoreState = true
}This:
- avoids duplicate screens
- preserves tab state
- restores state when returning to a tab
This is one of those small details that makes a big difference in how polished your app feels.
The structure that scales
If I had to summarize the approach that works for large apps, it would be this:
- One root NavHost
- Nested graphs per feature
- Centralized route helpers
- ViewModels for screen state
- Composables focused on UI only
This keeps navigation understandable even months later.
Putting it all together (MainActivity + graphs + routes + state)
To make this concrete, here’s a minimal but realistic wiring of everything together: a thin MainActivity, a root App, the AppNavHost, feature graphs, route helpers, and a screen with a ViewModel.
MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}App entry
@Composable
fun App() {
val navController = rememberNavController()
AppNavHost(navController = navController)
}
Graph constants
object Graph {
const val ROOT = "root"
const val AUTH = "auth"
const val MAIN = "main"
}Route helpers
sealed interface Destination { val route: String }
sealed interface AuthDest : Destination {
data object Login : AuthDest { override val route = "login" }
data object Signup : AuthDest { override val route = "signup" }
}
sealed interface MainDest : Destination {
data object Home : MainDest { override val route = "home" }
data object Settings : MainDest { override val route = "settings" }
data object UserDetails : MainDest {
override val route = "user/{userId}"
const val ARG = "userId"
fun create(userId: Long) = "user/$userId"
}
}Root NavHost
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = Graph.AUTH,
route = Graph.ROOT
) {
authNavGraph(navController)
mainNavGraph(navController)
}
}
Auth graph
fun NavGraphBuilder.authNavGraph(navController: NavHostController) {
navigation(startDestination = AuthDest.Login.route, route = Graph.AUTH) {
composable(AuthDest.Login.route) {
LoginRoute(
onLoginSuccess = {
navController.navigate(Graph.MAIN) {
popUpTo(Graph.AUTH) { inclusive = true }
}
},
onSignup = { navController.navigate(AuthDest.Signup.route) }
)
}
composable(AuthDest.Signup.route) {
SignupRoute(onDone = { navController.popBackStack() })
}
}
}Main graph (with arguments)
fun NavGraphBuilder.mainNavGraph(navController: NavHostController) {
navigation(startDestination = MainDest.Home.route, route = Graph.MAIN) {
composable(MainDest.Home.route) {
HomeRoute(
onOpenSettings = { navController.navigate(MainDest.Settings.route) },
onOpenUser = { id -> navController.navigate(MainDest.UserDetails.create(id)) }
)
}
composable(MainDest.Settings.route) {
SettingsRoute()
}
composable(
route = MainDest.UserDetails.route,
arguments = listOf(navArgument(MainDest.UserDetails.ARG) {
type = NavType.LongType
})
) { entry ->
val userId = entry.arguments?.getLong(MainDest.UserDetails.ARG)
?: return@composable
UserDetailsRoute(userId = userId)
}
}
}Route vs UI (keeping NavController out of leaf UI)
@Composable
fun HomeRoute(
onOpenSettings: () -> Unit,
onOpenUser: (Long) -> Unit
) {
HomeScreen(
onOpenSettings = onOpenSettings,
onOpenUser = onOpenUser
)
}
@Composable
fun HomeScreen(
onOpenSettings: () -> Unit,
onOpenUser: (Long) -> Unit
) {
Column {
Button(onClick = onOpenSettings) { Text("Settings") }
Button(onClick = { onOpenUser(42L) }) { Text("Open User") }
}
}
ViewModel-backed state on a destination
data class UserDetailsUiState(
val isLoading: Boolean = true,
val name: String = "",
val error: String? = null
)
class UserDetailsViewModel(
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val userId: Long = checkNotNull(savedStateHandle[MainDest.UserDetails.ARG])
private val _uiState = MutableStateFlow(UserDetailsUiState())
val uiState: StateFlow<UserDetailsUiState> = _uiState
init {
// fake load
viewModelScope.launch {
delay(300)
_uiState.value = UserDetailsUiState(isLoading = false, name = "User #$userId")
}
}
}
@Composable
fun UserDetailsRoute(
userId: Long,
viewModel: UserDetailsViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
UserDetailsScreen(uiState = uiState)
}
@Composable
fun UserDetailsScreen(uiState: UserDetailsUiState) {
when {
uiState.isLoading -> Text("Loading...")
uiState.error != null -> Text("Error: ${uiState.error}")
else -> Text("Hello ${uiState.name}")
}
}
This is the full loop:
- MainActivity hosts the app
- App owns the NavController
- AppNavHost defines the root graph
- Feature graphs group related flows
- Route helpers define destinations and arguments
- Route composables handle navigation wiring
- ViewModels own screen state
- UI composables render state and emit events
If you follow this structure, you can keep adding screens, flows, and features without your navigation turning into a giant, fragile file.
Conclusion
My previous article focused on helping Compose navigation click. This one is about making it scale.
For small apps, a single NavHost and a few routes are enough. But once your app grows, navigation is no longer just about moving between screens. It becomes part of your architecture. The biggest shift for me was realizing this:
Navigation should stay simple. Structure is what makes it powerful.
Once you separate graphs, routes, and state properly, everything becomes easier to reason about.
And more importantly, easier to maintain when your app inevitably grows.
If this helped, I would really appreciate a clap so more developers can find it, a follow if you want more practical mobile engineering write-ups, or a comment with how you are currently structuring navigation in your app.
That is usually where the most interesting discussions happen.
Scaling Navigation in Jetpack Compose: From Simple Apps to Real-World Architecture was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.