Navigation Compose: Create Your First NavHost

Quick answer: Create one NavController high in your app with rememberNavController(), place a NavHost beside it, then map type-safe route objects to destination composables. Let the NavHost call navigate() and popBackStack(); pass simple callbacks such as onOpenProfile(id) into screens instead of passing NavController to every child.

Navigation Compose lets a Compose app swap destinations while the navigation library owns the back stack. A small app only needs three ideas:

PartResponsibility
NavControllerNavigate, go back, and own the back stack
NavHostDisplay the destination that matches the current back-stack entry
Navigation graphMap route types to destination composables

For new Navigation Compose code, use the built-in type-safe route APIs. They have been available since Navigation 2.8.0 and replace fragile string routes with serializable Kotlin types. The official type-safety guide is the source of the pattern used below.

Add Navigation Compose and Kotlin serialization

Add the androidx.navigation:navigation-compose artifact to your app and enable the Kotlin serialization plugin plus the kotlinx-serialization-json runtime. If you use a version catalog, declare these once in libs.versions.toml and apply the serialization plugin in the app module.

The type-safe route APIs need Navigation 2.8.0 or higher. Keep the Navigation artifact version and Kotlin serialization plugin aligned with your project’s dependency strategy; the Compose BOM guide explains why the Compose BOM manages Compose libraries but not every AndroidX dependency.

Define routes as Kotlin types

Use a serializable object for a screen without arguments and a serializable data class for a screen that needs an argument:

import kotlinx.serialization.Serializable

@Serializable
data object HomeRoute

@Serializable
data class ProfileRoute(
    val userId: String,
)

The route types are your navigation contract. HomeRoute has no arguments, while ProfileRoute requires one userId. This gives the compiler useful checks when you navigate and avoids hand-built strings such as "profile/$userId".

Pass the smallest piece of data the next destination needs—normally an ID or key, not a large model object. The Android argument guidance recommends that each destination load its own data from the minimum necessary information. This improves restoration behavior and avoids stale copies of complex objects in navigation arguments.

Create your first NavHost

Create the controller high enough in the hierarchy to own app navigation, then connect it to a NavHost:

import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute

@Composable
fun AppNavigation(
    modifier: Modifier = Modifier,
) {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = HomeRoute,
        modifier = modifier,
    ) {
        composable<HomeRoute> {
            HomeScreen(
                onOpenProfile = { userId ->
                    navController.navigate(ProfileRoute(userId))
                },
            )
        }

        composable<ProfileRoute> { backStackEntry ->
            val profile = backStackEntry.toRoute<ProfileRoute>()

            ProfileScreen(
                userId = profile.userId,
                onBack = { navController.popBackStack() },
            )
        }
    }
}

startDestination defines the first destination shown in the app. Each composable<T> declares a destination for one route type. Calling navController.navigate(ProfileRoute(userId)) adds that destination to the back stack; popBackStack() returns to the previous entry.

The toRoute<ProfileRoute>() call reconstructs the typed route from the NavBackStackEntry. There is no manual argument lookup or string parsing.

Keep screens independent from NavController

HomeScreen should describe user intent, not know how the app implements navigation:

@Composable
fun HomeScreen(
    onOpenProfile: (String) -> Unit,
) {
    Button(onClick = { onOpenProfile("user-42") }) {
        Text("Open profile")
    }
}

@Composable
fun ProfileScreen(
    userId: String,
    onBack: () -> Unit,
) {
    Column {
        Text("Profile: $userId")
        TextButton(onClick = onBack) {
            Text("Back")
        }
    }
}

The NavHost wires onOpenProfile to navigate(). The screen only exposes an event. This makes it easier to preview HomeScreen, reuse it in a different navigation shell, and test that it emits the correct intent. It also leaves room for a larger screen to show detail content beside the list instead of navigating.

The Android navigation documentation likewise recommends passing navigation callbacks to composables rather than passing the NavController itself to every destination.

Read route arguments in a destination ViewModel

If a destination loads screen data through a ViewModel, retrieve the type-safe route from its SavedStateHandle:

class ProfileViewModel(
    savedStateHandle: SavedStateHandle,
    private val userRepository: UserRepository,
) : ViewModel() {
    private val route = savedStateHandle.toRoute<ProfileRoute>()

    val user = userRepository.observeUser(route.userId)
}

The route provides only the userId; the destination’s state holder loads the actual profile data. This keeps data ownership clear and works cleanly with the screen-state pattern in State Hoisting.

Use a single app-level navigation entry point

For a small app, AppNavigation() can contain every destination. As features grow, split graph-building functions by feature and assemble them at the app level:

fun NavGraphBuilder.settingsGraph(
    navController: NavController,
    onNavigateToAccount: () -> Unit,
) {
    composable<SettingsRoute> {
        SettingsScreen(
            onOpenAccount = onNavigateToAccount,
        )
    }
}

The feature may use navController for destinations inside that feature, while cross-feature navigation remains a callback. The app-level NavHost decides which route fulfills that callback. This keeps feature modules from importing one another’s route types just to navigate.

Do not over-engineer a two-screen project. Start with one readable graph, then split only when its destination list stops fitting the feature boundary in your head.

Navigation state answers “which destination is visible?” Screen state answers “what does this destination render?” Keep them separate:

  • Use the navigation back stack for routes and back behavior.
  • Use a ViewModel or UI-scoped state holder for the destination’s UI state.
  • Pass route IDs to identify a destination; load its screen data in that destination.

This separation helps avoid a common bug: passing a full User, Order, or list result through navigation, then showing stale data after it changes elsewhere.

Common mistakes

Passing string routes everywhere

String routes work, but a typo or mismatched argument can fail at runtime. For new projects using Navigation 2.8.0 or later, serializable route types make the destination contract explicit and compile-time checked.

Passing a full object to the next screen

Pass an ID, then load the latest object in the destination. Navigation arguments have limited saved-state space and should not become a second data cache.

Passing NavController into every button and list item

Leaf UI should emit events such as onOpenProfile. Keep calls to navigate() near the graph or route composable. This keeps UI components portable and previews simple.

Treating a destination as a whole feature architecture

NavHost selects destinations; it does not replace state holders, repositories, or screen UI state. Keep business rules outside composables and use one state owner per screen.

Forgetting the system back path

When adding a custom back button, call popBackStack() rather than navigating to the previous screen as a new destination. Popping preserves the existing back-stack semantics.

Verify your first graph

  1. The app starts on the expected startDestination.
  2. Each route type appears exactly once in the intended graph.
  3. A destination with arguments receives an ID and loads its own data.
  4. System Back and your custom back action return to the previous entry.
  5. Screens accept callbacks and state, not a NavController parameter.

For an app shell around your NavHost, use a Scaffold and keep its insets in place; Scaffold and Window Insets shows that setup. If you are evaluating the separate, newer Navigation 3 API, see the existing Navigation 3 guide—it is not a drop-in replacement for this navigation-compose NavHost example.

FAQ

Do I need a NavHost for every screen?

No. Most apps have one app-level NavHost that contains or assembles feature graphs. A nested graph is useful when a feature has its own related destinations, not for every individual screen.

Can I navigate with strings instead of route objects?

Yes, but type-safe routes are the stronger default in Navigation 2.8.0 and later. They avoid manually constructing routes and make argument types visible in the route definition.

Where should rememberNavController() live?

At a high app-level composable that owns navigation. It should outlive individual destinations, but leaf screens should receive navigation callbacks rather than the controller itself.