Logo
Navigation Component in Jetpack Compose: Type-Safe Routes, Nested Graphs & Multi-Module Architecture

Navigation Component in Jetpack Compose: Type-Safe Routes, Nested Graphs & Multi-Module Architecture

By Sagar Maiyad  Nov 25, 2025

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 position
  • launchSingleTop = true - Prevents duplicate screens when users spam-tap a tab
  • restoreState = 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.

Android Jetpack Compose Navigation Architecture Kotlin

Author

Sagar Maiyad
Written By
Sagar Maiyad

Sagar Maiyad - Android Team Lead specializing in Kotlin, Jetpack Compose, Flutter, and Node.js development. Building practical Android apps with 2M+ downloads and sharing real-world development insights.

View All Posts →

Latest Post

Latest Tags