I'll be honest - navigation in Android has always been a pain. Fragment transactions, back stack bugs, losing state when rotating the screen... I've spent countless hours debugging navigation issues over the years.
When I started using Navigation Component with Jetpack Compose, things got better. Not perfect (we'll get to the gotchas), but definitely better. After implementing navigation across multiple production apps, I wanted to share what actually works - and what doesn't.
The Problem with String-Based Routes
If you've worked with Navigation Compose before, you've probably written something like this:
navController.navigate("profile_screen/$userId")
Looks fine, right? Until you typo it as "profil_screen/$userId" somewhere and spend 20 minutes wondering why your app crashes. Ask me how I know.
The fix? Stop using raw strings. I use a sealed class approach that catches these mistakes at compile time:
sealed class AppRoute(val route: String) {
companion object {
const val AUTH_GRAPH_PATH = "auth_graph"
const val MAIN_GRAPH_PATH = "main_graph"
}
data object AuthGraph : AppRoute(AUTH_GRAPH_PATH)
data object MainGraph : AppRoute(MAIN_GRAPH_PATH)
data object SignIn : AppRoute("$AUTH_GRAPH_PATH/sign_in_screen") {
const val referralSource = "referralSource"
}
data object VerifyCode : AppRoute("$AUTH_GRAPH_PATH/verify_code_screen") {
const val userEmail = "userEmail"
const val expirySeconds = "expirySeconds"
}
data object Dashboard : AppRoute("$MAIN_GRAPH_PATH/dashboard_screen")
data object UserProfile : AppRoute("$MAIN_GRAPH_PATH/profile_screen")
fun withArgs(vararg args: Any?): String {
return buildString {
append(route)
args.forEach { append("/$it") }
}
}
fun withArgsFormat(vararg args: String?): String {
return buildString {
append(route)
args.forEach { append("/{$it}") }
}
}
}
Now instead of navController.navigate("profile_screen/$userId"), I write navController.navigate(AppRoute.UserProfile.withArgs(userId)). Typo? Compiler yells at me. Problem solved before it reaches production.
Quick tip: Keep all your routes in a single file. When you have 50+ screens, you'll thank yourself for not having to hunt through the codebase to find route definitions.
Bottom Navigation That Actually Works
Bottom navigation sounds simple until you implement it. The first time I built it, tapping between tabs kept adding screens to the back stack. Users would hit back 15 times trying to exit the app. Embarrassing.
Here's the setup I use now:
sealed class BottomNavItem(
val route: String,
@StringRes val labelResId: Int,
@DrawableRes val iconSelected: Int,
@DrawableRes val iconUnselected: Int
) {
data object Home : BottomNavItem(
route = AppRoute.Dashboard.route,
labelResId = R.string.tab_home,
iconSelected = R.drawable.ic_home_filled,
iconUnselected = R.drawable.ic_home_outline
)
data object Search : BottomNavItem(
route = AppRoute.Search.route,
labelResId = R.string.tab_search,
iconSelected = R.drawable.ic_search_filled,
iconUnselected = R.drawable.ic_search_outline
)
data object Favorites : BottomNavItem(
route = AppRoute.Favorites.route,
labelResId = R.string.tab_favorites,
iconSelected = R.drawable.ic_favorite_filled,
iconUnselected = R.drawable.ic_favorite_outline
)
data object Settings : BottomNavItem(
route = AppRoute.Settings.route,
labelResId = R.string.tab_settings,
iconSelected = R.drawable.ic_settings_filled,
iconUnselected = R.drawable.ic_settings_outline
)
companion object {
val items = listOf(Home, Search, Favorites, Settings)
}
}
And the actual navigation bar:
@Composable
fun AppBottomBar(
navController: NavController,
currentTabIndex: Int,
onTabSelected: (Int) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
BottomNavItem.items.forEachIndexed { index, item ->
val isSelected = currentTabIndex == index
Box(
modifier = Modifier
.weight(1f)
.clickable {
onTabSelected(index)
navController.navigate(item.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painter = painterResource(
if (isSelected) item.iconSelected else item.iconUnselected
),
contentDescription = stringResource(item.labelResId),
tint = if (isSelected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
if (isSelected) {
Text(
text = stringResource(item.labelResId),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
The three flags in the navigate block are crucial - I learned this the hard way:
popUpTo(startDestination)+saveState = true- Without this, every tab switch adds to the back stack AND you lose scroll positionlaunchSingleTop = true- Prevents duplicate screens when users spam-tap a tabrestoreState = true- Brings back the tab's previous state
Miss any of these and you'll get bug reports. Trust me.
Nested Graphs (How I Organize Large Apps)
When your app grows beyond 10-15 screens, throwing everything into one NavHost becomes a mess. I split navigation by feature:
// Auth module owns its own navigation
fun NavGraphBuilder.authNavGraph(
navController: NavHostController,
onAuthComplete: () -> Unit
) {
navigation(
route = AppRoute.AuthGraph.route,
startDestination = AppRoute.Welcome.withArgsFormat(
AppRoute.Welcome.referralSource
)
) {
addWelcomeScreen(navController)
addSignInScreen(navController, onAuthComplete)
addSignUpScreen(navController)
addVerifyCodeScreen(navController)
addResetPasswordScreen(navController, onAuthComplete)
addForgotPasswordScreen(navController)
}
}
// Main app module
fun NavGraphBuilder.mainNavGraph(
navController: NavHostController,
onSignOut: () -> Unit
) {
navigation(
route = AppRoute.MainGraph.route,
startDestination = AppRoute.Dashboard.route
) {
addDashboardScreen(navController)
addHomeGraph(navController)
addSearchGraph(navController)
addFavoritesGraph(navController)
addSettingsGraph(navController, onSignOut)
addDetailsGraph(navController)
addProfileGraph(navController)
}
}
Then wire them together:
@Composable
fun RootNavigation(
navController: NavHostController = rememberNavController(),
startDestination: String = AppRoute.AuthGraph.route
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
authNavGraph(
navController = navController,
onAuthComplete = {
navController.navigate(AppRoute.MainGraph.route) {
popUpTo(AppRoute.AuthGraph.route) { inclusive = true }
}
}
)
mainNavGraph(
navController = navController,
onSignOut = {
navController.navigate(AppRoute.AuthGraph.route) {
popUpTo(AppRoute.MainGraph.route) { inclusive = true }
}
}
)
}
}
Why I prefer this: Each feature team can work on their navigation independently. The auth team doesn't need to know anything about the settings flow, and vice versa.
Adding Screen Transitions
Default navigation has no animations - screens just pop in and out. It feels cheap. I add slide + fade transitions to everything:
private fun NavGraphBuilder.addSignInScreen(
navController: NavHostController,
onAuthComplete: () -> Unit
) {
composable(
route = AppRoute.SignIn.withArgsFormat(AppRoute.SignIn.referralSource),
arguments = listOf(
navArgument(AppRoute.SignIn.referralSource) {
type = NavType.IntType
defaultValue = -1
}
),
enterTransition = {
slideInHorizontally { it } + fadeIn(tween(400))
},
exitTransition = {
slideOutHorizontally { -it } + fadeOut(tween(400))
},
popEnterTransition = {
slideInHorizontally { -it } + fadeIn(tween(400))
},
popExitTransition = {
slideOutHorizontally { it } + fadeOut(tween(400))
}
) { backStackEntry ->
val referralSource = backStackEntry.arguments
?.getInt(AppRoute.SignIn.referralSource)
SignInScreen(
referralSource = referralSource,
onNavigateBack = { navController.navigateUp() },
onForgotPassword = {
navController.navigate(
AppRoute.ForgotPassword.withArgs(referralSource)
)
},
onSignUp = {
navController.navigate(
AppRoute.SignUp.withArgs(referralSource)
)
},
onSignInSuccess = onAuthComplete
)
}
}
400ms feels right to me - fast enough to not annoy users, slow enough to actually see the transition. I tried 300ms initially but it felt too abrupt.
Gotcha: Don't forget popEnterTransition and popExitTransition. Without them, going back looks weird because the animations don't reverse properly.
Passing Multiple Arguments (The Ugly Part)
This is where Navigation Component gets annoying. Passing one or two arguments is fine. But what about six?
navController.navigate(
AppRoute.VerifyCode.withArgs(
userEmail,
expirySeconds,
sourceId,
sessionToken,
null, // optional param
originScreen
)
)
And the screen definition becomes verbose:
private fun NavGraphBuilder.addVerifyCodeScreen(
navController: NavHostController
) {
composable(
route = AppRoute.VerifyCode.withArgsFormat(
AppRoute.VerifyCode.userEmail,
AppRoute.VerifyCode.expirySeconds,
AppRoute.VerifyCode.sourceId,
AppRoute.VerifyCode.sessionToken,
AppRoute.VerifyCode.pendingUserId,
AppRoute.VerifyCode.originScreen
),
arguments = listOf(
navArgument(AppRoute.VerifyCode.userEmail) {
type = NavType.StringType
},
navArgument(AppRoute.VerifyCode.expirySeconds) {
type = NavType.IntType
defaultValue = 60
},
navArgument(AppRoute.VerifyCode.sourceId) {
type = NavType.IntType
defaultValue = -1
},
navArgument(AppRoute.VerifyCode.sessionToken) {
type = NavType.StringType
nullable = true
},
navArgument(AppRoute.VerifyCode.pendingUserId) {
type = NavType.StringType
nullable = true
},
navArgument(AppRoute.VerifyCode.originScreen) {
type = NavType.StringType
defaultValue = "sign_in"
}
)
) { backStackEntry ->
val args = backStackEntry.arguments
VerifyCodeScreen(
email = args?.getString(AppRoute.VerifyCode.userEmail) ?: "",
expirySeconds = args?.getInt(AppRoute.VerifyCode.expirySeconds) ?: 60,
sourceId = args?.getInt(AppRoute.VerifyCode.sourceId),
token = args?.getString(AppRoute.VerifyCode.sessionToken),
userId = args?.getString(AppRoute.VerifyCode.pendingUserId),
originScreen = args?.getString(AppRoute.VerifyCode.originScreen) ?: "sign_in",
onVerificationComplete = { newToken ->
navController.navigate(
AppRoute.CreatePassword.withArgs(sourceId, newToken, originScreen)
) {
popUpTo(AppRoute.Welcome.route) { inclusive = false }
}
},
onNavigateBack = { navController.navigateUp() }
)
}
}
Yeah, it's a lot of boilerplate. Some people use SafeArgs or custom serialization to clean this up. Honestly, I've just accepted the verbosity - it works and it's explicit about what's being passed.
Important: If you're passing complex objects, don't. Pass IDs and fetch the data in the destination screen. Navigation arguments are meant for primitives and simple strings.
Architecture Overview
Here's roughly how the navigation structure looks when everything is connected:
┌─────────────────────────────────────────────────────────┐
│ MainActivity │
│ │ │
│ NavHost │
│ ┌───────────────┴───────────────┐ │
│ ▼ ▼ │
│ AuthGraph MainGraph │
│ ├── Welcome ├── Dashboard │
│ ├── SignIn │ ├── HomeGraph │
│ ├── SignUp │ ├── SearchGraph │
│ ├── VerifyCode │ ├── Favorites │
│ ├── ForgotPassword │ └── Settings │
│ └── CreatePassword ├── DetailsGraph │
│ └── ProfileGraph │
└─────────────────────────────────────────────────────────┘
Deep Linking
If you need to open specific screens from push notifications or external links, add deep links to your composable:
composable(
route = AppRoute.ItemDetail.withArgsFormat(AppRoute.ItemDetail.itemId),
arguments = listOf(
navArgument(AppRoute.ItemDetail.itemId) { type = NavType.StringType }
),
deepLinks = listOf(
navDeepLink {
uriPattern = "https://example.com/item/{${AppRoute.ItemDetail.itemId}}"
},
navDeepLink {
uriPattern = "myapp://item/{${AppRoute.ItemDetail.itemId}}"
}
)
) { backStackEntry ->
ItemDetailScreen(
itemId = backStackEntry.arguments?.getString(AppRoute.ItemDetail.itemId)
)
}
Don't forget to add the intent filter in your manifest. I've seen people set up deep links in code and wonder why they don't work - the manifest configuration is easy to miss.
Common Mistakes I've Made (So You Don't Have To)
1. Forgetting inclusive = true when clearing back stack
After login, you want to clear the auth screens so users can't go back to them:
navController.navigate(AppRoute.MainGraph.route) {
popUpTo(AppRoute.AuthGraph.route) { inclusive = true } // Don't forget this!
}
Without inclusive = true, the Welcome screen stays in the back stack.
2. Not handling configuration changes
If you store navController in a regular variable instead of using rememberNavController(), you'll lose navigation state on rotation.
3. Navigating from non-UI threads
Navigation must happen on the main thread. If you're navigating after an API call, make sure you're on the right dispatcher.
4. Passing large objects as arguments
Just don't. Pass an ID, fetch the data in the destination. I learned this when my app started crashing with TransactionTooLargeException.
Wrapping Up
Navigation Component isn't perfect, but it beats manual Fragment transactions any day. The sealed class approach for routes has saved me from countless typos, and proper back stack handling makes the app feel polished.
If you're starting a new Compose project, set up your navigation structure early. Retrofitting this into an existing app is painful - I've done it twice and don't recommend it.