
Routes, Arguments, Nested Graphs, and Real-World Flows (Without the Headaches)
Introduction
A while back, I wrote SwiftUI Navigation in iOS: A Practical Guide. That article came from a very real place: I had gone back to some older iOS code and realized how much navigation logic I used to tolerate before the newer APIs finally made things click. Around the same time, I had a similar feeling while revisiting SwiftUI State in iOS: A Practical Guide, because state and navigation usually start becoming painful at exactly the same moment: when an app stops being a toy project and starts behaving like a real one. Now I’ve had that same realization again, but this time on the Android side.
I’ve spent a lot of time building mobile apps across both iOS and Android, and one thing I’ve noticed is that navigation always looks easy in the sample app stage. You have a home screen, maybe a detail screen, maybe a settings page, and everything feels manageable. 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 add deep links. Suddenly, navigation stops feeling like a tiny implementation detail and starts feeling like architecture.
That’s usually when the confusion begins.
Not because Jetpack Compose navigation is bad, but because most of us first learn it through very small examples. And small examples don’t prepare you for the moment when your app has fifteen screens, three flows, and a product manager asking why the back button goes somewhere weird after sign-in.
That is exactly why I wanted to write this article.
I wanted something similar in spirit to my iOS navigation article, but for Android developers working with Jetpack Compose. Not a dry API reference. Not a toy sample that only works for two screens. I wanted a guide that feels like something you would actually refer to while building a real app.
So in this article, I’m going to walk through the mental model that helped Compose navigation finally feel predictable to me. We’ll start simple, then move into the stuff that matters in actual apps: typed-ish routes, passing arguments, programmatic navigation, clearing stacks, bottom bar navigation, nested graphs, and deep links.
If you are newer to Jetpack Compose, this should give you a solid foundation. And if you’ve already shipped a few screens and still feel like navigation is “working, but kind of messy,” this is for you too.
By the end, you’ll have:
- A clean mental model for how Compose navigation works.
- Practical patterns for moving between screens without making your code harder to reason about.
- Copy-pasteable examples for arguments, nested graphs, bottom navigation, and deep links.
- A structure that scales much better once your app stops being a demo.
Context
Before Jetpack Compose, navigation in Android often meant Fragments, XML navigation graphs, Safe Args, fragment transactions, and a lot of code spread across activities and fragment managers. It worked, but it also came with a lot of ceremony.
Then Compose arrived, and with it came a different way of thinking.
In Compose, your UI is already state-driven. Navigation fits best when you think about it the same way: your current destination is just another piece of app state.
That mental shift helped me a lot.
Because when I first touched Compose Navigation, I made the classic mistake of treating it like magic. I would copy a NavHost, throw in a few composable() blocks, call navController.navigate(...), and move on. For two or three screens, that feels fine. But once I started adding real flows, I noticed the same thing I saw years ago in mobile apps generally: navigation becomes hard when route definitions, arguments, and stack behavior are not intentional.
Here’s the mental model that made Compose navigation click for me:
Your app has a navigation graph, your NavController moves through that graph, and every screen is identified by a route.
That’s it.
A route is just a string, but if you leave it as “just strings everywhere,” things can get messy fast. So the trick is to make those routes structured enough that your code stays readable.
In practical terms:
- NavHost is where you define the map of your screens.
- NavController is what you use to move through that map.
- A route identifies where you want to go.
- Back stack behavior depends on how you call navigate() and whether you use options like popUpTo, launchSingleTop, and restoreState.
Once you stop thinking of navigation as random jumps between screens and start thinking of it as movement through a graph with rules, the API starts feeling much less mysterious.
Quick Start
The smallest Compose navigation setup that won’t haunt you later
If you only remember one thing from this article, let it be this:
Define your routes clearly, centralize them, and don’t scatter route strings across your app.
Let’s start with a very small example.
1) Basic setup with NavHost and a couple of routes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
sealed class AppScreen(val route: String) {
data object Home : AppScreen("home")
data object Settings : AppScreen("settings")
}
@Composable
fun AppNavHost(
navController: NavHostController = rememberNavController()
) {
NavHost(
navController = navController,
startDestination = AppScreen.Home.route
) {
composable(AppScreen.Home.route) {
HomeScreen(
onOpenSettings = {
navController.navigate(AppScreen.Settings.route)
}
)
}
composable(AppScreen.Settings.route) {
SettingsScreen()
}
}
}
@Composable
fun HomeScreen(onOpenSettings: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Home",
style = MaterialTheme.typography.headlineMedium
)
Button(onClick = onOpenSettings) {
Text("Open Settings")
}
}
}
@Composable
fun SettingsScreen() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Settings",
style = MaterialTheme.typography.headlineMedium
)
}
}
This example is intentionally small, but it already shows a few habits I strongly prefer in real projects.
First, routes are centralized in a sealed class instead of being hardcoded strings in random places. Second, the HomeScreendoes not directly know about NavController; it receives a callback. That may feel unnecessary in tiny examples, but it pays off later because your UI stays easier to preview, test, and reason about.
That second point matters more than it first appears.
One of the easiest ways to make Compose screens harder to maintain is to let every screen directly grab and manipulate the NavController. It works. I’ve done it too. But over time, it turns screens into coordination layers instead of UI layers.
Passing navigation lambdas keeps things cleaner.
Why this structure is worth it
Even at this early stage, this setup gives you a few nice benefits:
- Your routes live in one place.
- Renaming screens becomes safer.
- Screens stay more reusable because they don’t depend directly on navigation internals.
- The NavHost becomes the source of truth for how screens are connected.
This is very similar to what I like about typed routing on iOS. Compose doesn’t give you the exact same model out of the box, but you can still structure your routes in a way that makes your app feel more deliberate and less stringly-typed.
Deep Dive
Passing arguments without turning your code into route-string soup
The moment navigation starts getting interesting is usually when one screen needs data from another.
For example:
- Open a product details page for product 42.
- Open a user profile for a given user ID.
- Open an edit screen with an item ID.
You can absolutely build routes manually with strings, but I strongly recommend wrapping that logic in helper functions so route construction stays consistent.
2) Define a route with arguments
sealed class AppScreen(val route: String) {
data object Home : AppScreen("home")
data object Settings : AppScreen("settings")
data object UserDetails : AppScreen("user/{userId}") {
fun createRoute(userId: Int): String = "user/$userId"
}
}Now let’s add the corresponding destination.
import androidx.navigation.NavType
import androidx.navigation.navArgument
NavHost(
navController = navController,
startDestination = AppScreen.Home.route
) {
composable(AppScreen.Home.route) {
HomeScreen(
onOpenSettings = {
navController.navigate(AppScreen.Settings.route)
},
onOpenUser = { userId ->
navController.navigate(AppScreen.UserDetails.createRoute(userId))
}
)
}
composable(AppScreen.Settings.route) {
SettingsScreen()
}
composable(
route = AppScreen.UserDetails.route,
arguments = listOf(
navArgument("userId") {
type = NavType.IntType
}
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getInt("userId") ?: return@composable
UserDetailsScreen(userId = userId)
}
}
And update HomeScreen:
@Composable
fun HomeScreen(
onOpenSettings: () -> Unit,
onOpenUser: (Int) -> Unit
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Home",
style = MaterialTheme.typography.headlineMedium
)
Button(onClick = onOpenSettings) {
Text("Open Settings")
}
Button(onClick = { onOpenUser(42) }) {
Text("Open User 42")
}
}
}
@Composable
fun UserDetailsScreen(userId: Int) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "User Details: $userId",
style = MaterialTheme.typography.headlineMedium
)
}
}
This is usually the point where Compose navigation starts making more sense to newer developers. A destination route can include placeholders, and when you navigate, you provide a concrete version of that route.
The big thing I’d emphasize here is this: never build argument routes ad hoc throughout the app.
Do not write "user/${id}" in seven different files.
That is how bugs sneak in.
Put route creation next to the route definition. Your future self will thank you.
Programmatic navigation and back stack control
One of the things that makes navigation annoying in real apps is not moving forward. Forward is easy. The hard part is controlling what happens to the back stack.
This comes up all the time:
- After login, the user should not go back to the login screen.
- After onboarding, you want to land in the main app and clear onboarding from history.
- When reselecting a bottom tab, you may not want duplicate copies of the same destination.
This is where navigate() options become very important.
3) Navigate and clear part of the back stack
navController.navigate("main") {
popUpTo("login") {
inclusive = true
}
}This says: navigate to main, and remove login from the back stack too.
That inclusive = true is the detail that makes the login screen disappear entirely from the back history.
If you leave it out, users may still be able to back their way into places you meant to leave behind.
4) A small auth flow example
sealed class AppScreen(val route: String) {
data object Login : AppScreen("login")
data object Home : AppScreen("home")
data object Settings : AppScreen("settings")
}
@Composable
fun AppNavHost(
navController: NavHostController = rememberNavController(),
isLoggedIn: Boolean = false
) {
val startDestination = if (isLoggedIn) AppScreen.Home.route else AppScreen.Login.route
NavHost(
navController = navController,
startDestination = startDestination
) {
composable(AppScreen.Login.route) {
LoginScreen(
onLoginSuccess = {
navController.navigate(AppScreen.Home.route) {
popUpTo(AppScreen.Login.route) {
inclusive = true
}
}
}
)
}
composable(AppScreen.Home.route) {
HomeScreen(
onOpenSettings = {
navController.navigate(AppScreen.Settings.route)
}
)
}
composable(AppScreen.Settings.route) {
SettingsScreen()
}
}
}
@Composable
fun LoginScreen(onLoginSuccess: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Login",
style = MaterialTheme.typography.headlineMedium
)
Button(onClick = onLoginSuccess) {
Text("Sign In")
}
}
}This is the kind of pattern I use a lot because it matches real product behavior. When auth succeeds, you do not want login hanging around in the back stack like a ghost from five seconds ago.
Avoiding duplicate destinations
Another navigation annoyance I hit early in Compose was duplicate destinations building up because I kept navigating to the same route repeatedly.
For example, if a user taps the same bottom nav item multiple times, you usually do not want five copies of the same screen.
That is where launchSingleTop helps.
navController.navigate(AppScreen.Home.route) {
launchSingleTop = true
}This tells the controller not to add another copy if the destination is already on top.
It sounds small, but it makes navigation feel much more stable in apps where users can repeatedly trigger the same route.
Bottom navigation in Compose
This is one of those topics that looks simple until you actually build it.
My first few Compose bottom bar implementations technically worked, but I always found myself tweaking them because back stack behavior was slightly off. The problem was never rendering the bar itself. The real challenge was making tab switching behave the way users expect.
Usually, users expect each tab to feel like its own section. They do not expect weird stack duplication. They also expect state restoration when they come back to a tab.
Here is a common and solid pattern.
5) Bottom navigation with state restoration
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.getValue
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.currentBackStackEntryAsState
sealed class BottomNavItem(
val route: String,
val label: String
) {
data object Feed : BottomNavItem("feed", "Feed")
data object Search : BottomNavItem("search", "Search")
data object Profile : BottomNavItem("profile", "Profile")
}
@Composable
fun MainScreen() {
val navController = rememberNavController()
val items = listOf(
BottomNavItem.Feed,
BottomNavItem.Search,
BottomNavItem.Profile
)
Scaffold(
bottomBar = {
BottomBar(navController = navController, items = items)
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = BottomNavItem.Feed.route
) {
composable(BottomNavItem.Feed.route) {
FeedScreen()
}
composable(BottomNavItem.Search.route) {
SearchScreen()
}
composable(BottomNavItem.Profile.route) {
ProfileScreen()
}
}
}
}
@Composable
fun BottomBar(
navController: NavHostController,
items: List<BottomNavItem>
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
NavigationBar {
items.forEach { item ->
val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = {
Icon(
imageVector = androidx.compose.material.icons.Icons.Default.Home,
contentDescription = item.label
)
},
label = {
Text(item.label)
}
)
}
}
}
A few things are happening here that are worth understanding.
- popUpTo(navController.graph.startDestinationId) prevents the back stack from growing in weird ways across tab switches.
- saveState = true preserves prior tab state where possible.
- restoreState = true restores it when the user returns.
- launchSingleTop = true avoids duplicate copies of the same top destination.
This combination is one of those things I wish someone had explained to me earlier, because it makes a very noticeable difference in how polished tab navigation feels.
Nested graphs for flows that belong together
Once your app grows, not every destination should sit flat in one giant NavHost block.
This is where nested graphs become extremely useful.
A very common example is authentication.
You might have:
- A login screen
- A signup screen
- A forgot password screen
Those three screens belong to one flow. They are related. Grouping them under a nested graph makes your navigation structure easier to understand.
6) Nested graph example
import androidx.navigation.navigation
object Graph {
const val ROOT = "root_graph"
const val AUTH = "auth_graph"
const val MAIN = "main_graph"
}
sealed class AppScreen(val route: String) {
data object Login : AppScreen("login")
data object Signup : AppScreen("signup")
data object ForgotPassword : AppScreen("forgot_password")
data object Home : AppScreen("home")
data object Settings : AppScreen("settings")
}
@Composable
fun AppNavHost(navController: NavHostController = rememberNavController()) {
NavHost(
navController = navController,
route = Graph.ROOT,
startDestination = Graph.AUTH
) {
navigation(
route = Graph.AUTH,
startDestination = AppScreen.Login.route
) {
composable(AppScreen.Login.route) {
LoginScreen(
onLoginSuccess = {
navController.navigate(Graph.MAIN) {
popUpTo(Graph.AUTH) {
inclusive = true
}
}
},
onSignup = {
navController.navigate(AppScreen.Signup.route)
},
onForgotPassword = {
navController.navigate(AppScreen.ForgotPassword.route)
}
)
}
composable(AppScreen.Signup.route) {
SignupScreen()
}
composable(AppScreen.ForgotPassword.route) {
ForgotPasswordScreen()
}
}
navigation(
route = Graph.MAIN,
startDestination = AppScreen.Home.route
) {
composable(AppScreen.Home.route) {
HomeScreen(
onOpenSettings = {
navController.navigate(AppScreen.Settings.route)
}
)
}
composable(AppScreen.Settings.route) {
SettingsScreen()
}
}
}
}
This structure reads much more like the actual product.
There is an auth flow.
There is a main app flow.
They are separate sections of the graph.
That separation becomes really helpful once your app gets more complicated. It also makes transitions like “user signed in, leave auth entirely” much easier to express.
Deep links in Compose
Deep links are one of those features that sound fancy until you realize they are just another way of entering the graph.
A push notification might open a detail page.
A marketing link might open a specific screen.
An email verification link might route into a flow.
Compose Navigation supports this directly.
7) Deep link example
import androidx.navigation.navDeepLink
sealed class AppScreen(val route: String) {
data object Home : AppScreen("home")
data object ArticleDetails : AppScreen("article/{articleId}") {
fun createRoute(articleId: String): String = "article/$articleId"
}
}
NavHost(
navController = navController,
startDestination = AppScreen.Home.route
) {
composable(AppScreen.Home.route) {
HomeScreen(
onOpenArticle = { articleId ->
navController.navigate(AppScreen.ArticleDetails.createRoute(articleId))
}
)
}
composable(
route = AppScreen.ArticleDetails.route,
arguments = listOf(
navArgument("articleId") { type = NavType.StringType }
),
deepLinks = listOf(
navDeepLink {
uriPattern = "myapp://article/{articleId}"
}
)
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId") ?: return@composable
ArticleDetailsScreen(articleId = articleId)
}
}
Now a URI like this can open the article details destination directly:
myapp://article/abc123
That is the nice part.
The important part is making sure your route and your deep link pattern stay aligned. If you are sloppy with naming or placeholders, deep links become much harder to maintain later.
This is another reason I like wrapping route creation in helper functions and centralizing destination definitions.
Passing more complex data
This is where I want to save people some pain.
When I first started with navigation in Android years ago, I had the same temptation many people do: “Why not just pass the full object?”
In Compose Navigation, you technically can serialize data and pass it around, but in most cases, I do not think that is the cleanest approach.
For real apps, I usually recommend this pattern instead:
- Pass a stable identifier through navigation.
- Load the actual object from a repository or ViewModel on the destination side.
So rather than passing a full User, pass userId.
Rather than passing a full Article, pass articleId.
Why?
Because routes are easier to reason about when they are small and stable. Also, once data gets large or changes over time, serializing whole objects into navigation starts feeling fragile.
If you stick to IDs for most navigation, your flow becomes more predictable.
A small route helper pattern I really like
Compose Navigation still uses string routes, so one thing I’ve found helpful is to make route definitions feel a little more structured.
Here is a simple pattern that scales well.
8) Route helper objects
sealed interface AppDestination {
val route: String
}
data object HomeDestination : AppDestination {
override val route = "home"
}
data object SettingsDestination : AppDestination {
override val route = "settings"
}
data object UserDestination : AppDestination {
override val route = "user/{userId}"
fun createRoute(userId: Int): String = "user/$userId"
const val ARG_USER_ID = "userId"
}Then use those constants everywhere instead of raw strings.
composable(
route = UserDestination.route,
arguments = listOf(
navArgument(UserDestination.ARG_USER_ID) {
type = NavType.IntType
}
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getInt(UserDestination.ARG_USER_ID)
?: return@composable
UserDetailsScreen(userId = userId)
}
This does not magically make routes compile-time perfect the way fully typed navigation can in some ecosystems, but it does reduce accidental inconsistencies a lot.
Keep NavController out of most leaf composables
This is more of an architectural habit than a hard rule, but it has helped me repeatedly.
Try to keep NavController close to the graph or screen-container level, and pass simple lambdas into child composables.
For example, prefer this:
@Composable
fun HomeRoute(navController: NavHostController) {
HomeScreen(
onOpenSettings = {
navController.navigate(SettingsDestination.route)
},
onOpenUser = { userId ->
navController.navigate(UserDestination.createRoute(userId))
}
)
}
And then keep the UI clean:
@Composable
fun HomeScreen(
onOpenSettings: () -> Unit,
onOpenUser: (Int) -> Unit
) {
Column {
Button(onClick = onOpenSettings) {
Text("Settings")
}
Button(onClick = { onOpenUser(99) }) {
Text("Open User")
}
}
}
Instead of this in every leaf composable:
@Composable
fun HomeScreen(navController: NavHostController) {
Button(
onClick = {
navController.navigate("settings")
}
) {
Text("Settings")
}
}
The second version is tempting because it is quick, but it couples your UI directly to navigation internals. The first version stays easier to test, reuse, preview, and refactor.
Common pitfalls I hit
These are the exact kinds of potholes I’ve stepped into while working with Compose navigation.
Pitfall 1: Hardcoding route strings everywhere
Mistake: Writing raw route strings in multiple files.
Why it hurts: It becomes easy to mistype a route, change one place and forget another, or build arguments inconsistently.
Fix: Centralize routes and route builders.
Pitfall 2: Letting every composable own navigation
Mistake: Passing NavController deep into the UI tree.
Why it hurts: Your composables become harder to reuse and test, and navigation logic gets scattered.
Fix: Keep navigation near the graph or screen-container level and pass lambdas down.
Pitfall 3: Forgetting back stack rules after auth or onboarding
Mistake: Navigating to the next screen but leaving old flow screens on the stack.
Why it hurts: Users can hit back and end up in places that no longer make sense.
Fix: Use popUpTo(...){ inclusive = true } intentionally.
Pitfall 4: Duplicate destinations from repeated taps
Mistake: Navigating to the same route over and over without launchSingleTop.
Why it hurts: Your stack gets cluttered and the app feels inconsistent.
Fix: Use launchSingleTop = true where repeated navigation is likely.
Pitfall 5: Passing entire objects through routes
Mistake: Treating navigation like a dump truck for full models.
Why it hurts: It becomes fragile, noisy, and harder to evolve.
Fix: Pass IDs or small stable values, then load the full data in the destination.
Pitfall 6: One giant flat graph for everything
Mistake: Keeping auth, onboarding, tabs, and app content all at one level.
Why it hurts: The graph becomes harder to reason about as the app grows.
Fix: Use nested graphs for distinct flows.
The mental model I’d keep
If I had to compress this whole article into a few ideas, it would be these:
- A route identifies a destination.
- NavHost defines the map.
- NavController moves through that map.
- Route structure matters more than people think.
- Back stack behavior is part of navigation design, not an afterthought.
- Nested graphs and route helpers make real apps much easier to maintain.
That is the point where Compose navigation stopped feeling random to me.
It stopped being “call navigate and hope it works” and started feeling more like a system I could reason about.
Conclusion
If you are building Android apps with Jetpack Compose, this is the navigation approach I would recommend betting on.
Not because it is the fanciest possible setup, but because it stays understandable as your app grows.
For tiny apps, a simple NavHost with a few routes is enough. But once you add arguments, auth flows, bottom tabs, onboarding, or deep links, the difference between “it works” and “it scales” usually comes down to structure.
For me, the biggest shift was realizing that Compose navigation is not really about memorizing APIs. It is about being intentional with route definitions, graph boundaries, and back stack behavior.
Once that clicked, the code got easier to reason about.
And honestly, that is what I want from navigation more than anything else. I do not need it to feel clever. I need it to feel predictable, especially three months later when I revisit the project and wonder what past me was thinking.
If this article helped, I’d really appreciate a quick clap so more Android developers can find it, a follow if you want more practical mobile engineering write-ups, or a comment with the kind of navigation flow you are dealing with right now. Tabs, auth, onboarding, nested graphs, weird back-stack bugs, all of it. Those are usually the situations where the real learning happens.
Thanks for reading. This was one of those topics I wanted to write down properly because I keep seeing the same confusion show up in real projects, and honestly, I’ve hit most of these problems myself too.
Navigation in Android Jetpack Compose: A Practical Guide was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.