Navigation in Compose replaces the XML Navigation Component with a code-first, type-safe approach. Instead of defining navigation graphs in XML and using Fragments, you define destinations as composable functions and navigate with route strings (or type-safe objects). This guide covers everything — NavHost, NavController, passing arguments, nested navigation graphs, bottom navigation integration, deep links, and the new type-safe navigation API.
Setup
// build.gradle.kts
dependencies {
// Core Compose Navigation
implementation("androidx.navigation:navigation-compose:2.8.3")
// navigation-compose is the Compose-specific navigation library
// Hilt integration (for hiltViewModel in destinations)
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Kotlin Serialization (for type-safe navigation — recommended)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
plugins {
// Kotlin Serialization plugin — required for type-safe routes
alias(libs.plugins.kotlin.serialization)
}
Core Concepts
// Three core components:
//
// 1. NavController — the navigation STATE holder
// Tracks the back stack, current destination, and handles navigation actions
// NavHostController is a CLASS that extends NavController
//
// 2. NavHost — the CONTAINER composable
// Displays the current destination's composable based on NavController state
// NavHost is a COMPOSABLE FUNCTION
//
// 3. Destinations — COMPOSABLE FUNCTIONS registered with routes
// Each destination is a composable function tied to a route string or object
// composable() is a FUNCTION on NavGraphBuilder
// Navigation flow:
//
// NavController NavHost
// ┌──────────────┐ ┌──────────────────────┐
// │ Back stack: │ │ │
// │ "home" │────────────→│ Shows: HomeScreen() │
// │ │ │ │
// │ navigate() │ │ Recomposes when │
// │ → "detail/5" │────────────→│ Shows: DetailScreen()│
// │ │ │ │
// │ popBackStack()│ │ │
// │ ← "home" │────────────→│ Shows: HomeScreen() │
// └──────────────┘ └──────────────────────┘
Basic Navigation — String Routes
@Composable
fun MyApp() {
val navController = rememberNavController()
// rememberNavController() is a COMPOSABLE FUNCTION from navigation-compose
// Creates and remembers a NavHostController across recompositions
// Returns NavHostController which is a CLASS
NavHost(
// NavHost is a COMPOSABLE FUNCTION — the container for destinations
navController = navController,
startDestination = "home" // first screen shown
) {
// This lambda is NavGraphBuilder.() — NOT @Composable
// NavGraphBuilder is a CLASS that registers destinations
composable("home") {
// composable() is an EXTENSION FUNCTION on NavGraphBuilder
// Registers a destination with route string "home"
// The lambda IS @Composable — your screen content
HomeScreen(
onArticleClick = { articleId ->
navController.navigate("detail/$articleId")
// navigate() is a FUNCTION on NavController
// Pushes a new destination onto the back stack
}
)
}
composable("detail/{articleId}") {
// {articleId} is a PATH ARGUMENT — extracted from the route
val articleId = it.arguments?.getString("articleId") ?: ""
// "it" is NavBackStackEntry — a CLASS holding the destination's state
// arguments is a PROPERTY on NavBackStackEntry — returns Bundle?
DetailScreen(
articleId = articleId,
onBack = { navController.popBackStack() }
// popBackStack() is a FUNCTION on NavController
// Pops the current destination, returns to previous
)
}
}
}
Passing Arguments
Path arguments (required)
// Define route with {placeholder}
composable("detail/{articleId}") { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId") ?: ""
DetailScreen(articleId = articleId)
}
// Navigate with actual value in the path
navController.navigate("detail/123")
// The route becomes "detail/123" — articleId = "123"
Typed arguments with NavArgument
composable(
route = "detail/{articleId}",
arguments = listOf(
navArgument("articleId") {
// navArgument() is a TOP-LEVEL FUNCTION from navigation-compose
// Returns NamedNavArgument — a CLASS
type = NavType.StringType
// NavType is an ABSTRACT CLASS with predefined types:
// NavType.StringType, NavType.IntType, NavType.LongType,
// NavType.FloatType, NavType.BoolType
}
)
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId") ?: ""
DetailScreen(articleId = articleId)
}
// Int argument
composable(
route = "detail/{articleId}",
arguments = listOf(
navArgument("articleId") { type = NavType.IntType }
)
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getInt("articleId") ?: 0
DetailScreen(articleId = articleId)
}
navController.navigate("detail/42") // articleId = 42 (Int)
Query parameter arguments (optional)
composable(
route = "articles?category={category}&sort={sort}",
arguments = listOf(
navArgument("category") {
type = NavType.StringType
defaultValue = "all" // optional — has a default
nullable = true
},
navArgument("sort") {
type = NavType.StringType
defaultValue = "newest"
}
)
) { backStackEntry ->
val category = backStackEntry.arguments?.getString("category") ?: "all"
val sort = backStackEntry.arguments?.getString("sort") ?: "newest"
ArticleListScreen(category = category, sortOrder = sort)
}
// Navigate with optional params
navController.navigate("articles") // uses defaults
navController.navigate("articles?category=tech") // category=tech, sort=newest
navController.navigate("articles?category=tech&sort=oldest") // both specified
Type-Safe Navigation (Kotlin Serialization)
The modern, recommended approach — uses Kotlin objects as routes instead of strings. Requires Kotlin Serialization plugin:
// Step 1: Define routes as serializable objects
@Serializable
// @Serializable is an ANNOTATION from kotlinx.serialization
object Home // no arguments — use object
@Serializable
data class Detail(val articleId: String) // with arguments — use data class
@Serializable
data class ArticleList(
val category: String = "all", // optional with default
val sort: String = "newest"
)
@Serializable
object Settings
@Serializable
object Profile
// Step 2: Build the NavHost with typed routes
@Composable
fun MyApp() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Home // pass the OBJECT, not a string
) {
composable<Home> {
// composable<T>() is an INLINE REIFIED EXTENSION FUNCTION on NavGraphBuilder
// Type parameter T is the route class — type-safe, no strings!
HomeScreen(
onArticleClick = { articleId ->
navController.navigate(Detail(articleId = articleId))
// navigate() accepts the route OBJECT directly
// Arguments are passed as constructor parameters — type-safe!
}
)
}
composable<Detail> { backStackEntry ->
val detail = backStackEntry.toRoute<Detail>()
// toRoute<T>() is an EXTENSION FUNCTION on NavBackStackEntry
// Deserializes the route back to your data class — type-safe!
DetailScreen(
articleId = detail.articleId,
onBack = { navController.popBackStack() }
)
}
composable<ArticleList> { backStackEntry ->
val route = backStackEntry.toRoute<ArticleList>()
ArticleListScreen(
category = route.category,
sort = route.sort
)
}
composable<Settings> {
SettingsScreen()
}
}
}
// Navigate — fully type-safe, no strings, no wrong argument types
navController.navigate(Detail(articleId = "abc-123"))
navController.navigate(ArticleList(category = "tech", sort = "popular"))
navController.navigate(Settings)
// Benefits over string routes:
// ✅ Compile-time safety — wrong argument types won't compile
// ✅ Refactoring safe — rename a property, all usages update
// ✅ No string typos — "detial/123" would compile, Detail(articleId = "123") can't be wrong
// ✅ IDE autocomplete — see available arguments as constructor params
Navigation with ViewModel
// Each navigation destination gets its OWN ViewModel (scoped to the destination)
composable<Detail> { backStackEntry ->
val viewModel: DetailViewModel = hiltViewModel()
// hiltViewModel() is a COMPOSABLE FUNCTION from hilt-navigation-compose
// Creates a ViewModel scoped to THIS destination's back stack entry
// When user navigates away and this destination is popped → ViewModel is cleared
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
DetailScreen(
uiState = uiState,
onBookmark = viewModel::toggleBookmark,
onBack = { navController.popBackStack() }
)
}
// Accessing route arguments in ViewModel via SavedStateHandle
@HiltViewModel
class DetailViewModel @Inject constructor(
private val repository: ArticleRepository,
private val savedStateHandle: SavedStateHandle
// SavedStateHandle automatically receives navigation arguments!
) : ViewModel() {
// With type-safe navigation:
private val route = savedStateHandle.toRoute<Detail>()
// toRoute<T>() is an EXTENSION FUNCTION on SavedStateHandle
private val articleId = route.articleId
// With string routes:
// private val articleId: String = savedStateHandle.get<String>("articleId") ?: ""
val uiState: StateFlow<DetailUiState> = repository.getArticle(articleId)
.map { DetailUiState.Success(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), DetailUiState.Loading)
}
Navigation Options — Controlling the Back Stack
// Basic navigation — push destination onto back stack
navController.navigate(Detail(articleId = "123"))
// Pop back to previous destination
navController.popBackStack()
// Navigate with options
navController.navigate(Home) {
// This lambda receiver is NavOptionsBuilder — a CLASS
// Pop up to a destination (inclusive = also pop that destination)
popUpTo(Home) {
// popUpTo() is a FUNCTION on NavOptionsBuilder
inclusive = true // also pop Home itself → clean stack
saveState = true // save state of popped destinations
}
// Avoid duplicate destinations on the back stack
launchSingleTop = true
// If Home is already at the top → don't create another instance
// Similar to singleTop launch mode in Activities
// Restore state when navigating to a previously saved destination
restoreState = true
}
// Common patterns:
// 1. NAVIGATE AND CLEAR STACK (like logout)
navController.navigate(Login) {
popUpTo(0) { inclusive = true } // pop everything
}
// 2. SINGLE TOP (avoid duplicate)
navController.navigate(Home) {
launchSingleTop = true
}
// 3. BOTTOM NAV PATTERN (save/restore each tab's state)
navController.navigate(destination) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
Bottom Navigation
// Define bottom nav destinations
@Serializable object HomeTab
@Serializable object SearchTab
@Serializable object BookmarksTab
@Serializable object ProfileTab
// Bottom nav items
data class BottomNavItem(
val route: Any, // the @Serializable route object
val icon: ImageVector,
val label: String
)
val bottomNavItems = listOf(
BottomNavItem(HomeTab, Icons.Default.Home, "Home"),
BottomNavItem(SearchTab, Icons.Default.Search, "Search"),
BottomNavItem(BookmarksTab, Icons.Default.Bookmark, "Bookmarks"),
BottomNavItem(ProfileTab, Icons.Default.Person, "Profile")
)
@Composable
fun MainScreen() {
val navController = rememberNavController()
// Track current destination for highlighting the active tab
val navBackStackEntry by navController.currentBackStackEntryAsState()
// currentBackStackEntryAsState() is an EXTENSION FUNCTION on NavController
// Returns State<NavBackStackEntry?> — recomposes when destination changes
Scaffold(
bottomBar = {
NavigationBar {
// NavigationBar is a COMPOSABLE FUNCTION from Material3
bottomNavItems.forEach { item ->
val isSelected = navBackStackEntry?.destination?.route ==
item.route::class.qualifiedName
NavigationBarItem(
// NavigationBarItem is a COMPOSABLE FUNCTION from Material3
selected = isSelected,
onClick = {
navController.navigate(item.route) {
// Save and restore state for each tab
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label) }
)
}
}
}
) { padding ->
NavHost(
navController = navController,
startDestination = HomeTab,
modifier = Modifier.padding(padding)
) {
composable<HomeTab> {
HomeScreen(
onArticleClick = { navController.navigate(Detail(it)) }
)
}
composable<SearchTab> { SearchScreen() }
composable<BookmarksTab> { BookmarksScreen() }
composable<ProfileTab> { ProfileScreen() }
// Detail screen — navigated from HomeTab
composable<Detail> { backStackEntry ->
val route = backStackEntry.toRoute<Detail>()
DetailScreen(articleId = route.articleId)
}
}
}
}
Nested Navigation Graphs
// Group related destinations into nested graphs
// Useful for: auth flow, onboarding flow, checkout flow
@Serializable object AuthGraph // the graph route (not a destination)
@Serializable object Login
@Serializable object Register
@Serializable object ForgotPassword
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = AuthGraph) {
// Nested graph — auth flow
navigation<AuthGraph>(startDestination = Login) {
// navigation<T>() is an INLINE EXTENSION FUNCTION on NavGraphBuilder
// Creates a nested navigation graph with T as the graph's route
// startDestination is the first destination shown when entering the graph
composable<Login> {
LoginScreen(
onLoginSuccess = {
navController.navigate(HomeTab) {
popUpTo(AuthGraph) { inclusive = true }
// Pop entire auth graph after login
}
},
onRegister = { navController.navigate(Register) },
onForgotPassword = { navController.navigate(ForgotPassword) }
)
}
composable<Register> {
RegisterScreen(onBack = { navController.popBackStack() })
}
composable<ForgotPassword> {
ForgotPasswordScreen(onBack = { navController.popBackStack() })
}
}
// Main app destinations
composable<HomeTab> { HomeScreen() }
composable<Detail> { /* ... */ }
}
}
// Benefits of nested graphs:
// - Logical grouping of related screens
// - Pop entire flow at once (popUpTo(AuthGraph) { inclusive = true })
// - Shared ViewModel across screens in the same graph
// - Cleaner, more maintainable navigation code
Shared ViewModel Across Navigation Destinations
// Share a ViewModel between screens in a nested navigation graph
@Serializable object CheckoutGraph
@Serializable object Cart
@Serializable object Shipping
@Serializable object Payment
@HiltViewModel
class CheckoutViewModel @Inject constructor(/* ... */) : ViewModel() {
val cartItems = MutableStateFlow<List<CartItem>>(emptyList())
val shippingAddress = MutableStateFlow<Address?>(null)
}
// In the NavHost:
navigation<CheckoutGraph>(startDestination = Cart) {
composable<Cart> {
// Get the parent graph's back stack entry for shared ViewModel
val parentEntry = remember(it) {
navController.getBackStackEntry(CheckoutGraph)
}
// getBackStackEntry() is a FUNCTION on NavController
// Returns the NavBackStackEntry for the given route
val checkoutViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
// hiltViewModel(viewModelStoreOwner) uses the PARENT entry's ViewModelStore
// All screens in the checkout graph share the SAME ViewModel instance
CartScreen(viewModel = checkoutViewModel)
}
composable<Shipping> {
val parentEntry = remember(it) {
navController.getBackStackEntry(CheckoutGraph)
}
val checkoutViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
// SAME instance as CartScreen — shared across the checkout flow
ShippingScreen(viewModel = checkoutViewModel)
}
composable<Payment> {
val parentEntry = remember(it) {
navController.getBackStackEntry(CheckoutGraph)
}
val checkoutViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
PaymentScreen(viewModel = checkoutViewModel)
}
}
// When user completes checkout and navigates away from CheckoutGraph:
// navController.navigate(Home) { popUpTo(CheckoutGraph) { inclusive = true } }
// CheckoutViewModel is CLEARED — cart data is gone (correct behavior)
Deep Links
// Handle deep links — external URLs that open specific screens in your app
// With string routes:
composable(
route = "detail/{articleId}",
deepLinks = listOf(
navDeepLink {
// navDeepLink { } is a TOP-LEVEL FUNCTION that creates a NavDeepLink
// NavDeepLink is a CLASS representing a deep link pattern
uriPattern = "https://www.example.com/articles/{articleId}"
// When this URL is opened, navigate to this destination
// {articleId} is extracted from the URL path
}
)
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId") ?: ""
DetailScreen(articleId = articleId)
}
// With type-safe routes:
composable<Detail>(
deepLinks = listOf(
navDeepLink<Detail>(basePath = "https://www.example.com/articles")
// navDeepLink<T>() is a TOP-LEVEL FUNCTION
// Automatically maps Detail's properties to URL path segments
)
) { /* ... */ }
// AndroidManifest.xml — declare the intent filter
// <activity android:name=".MainActivity">
// <intent-filter>
// <action android:name="android.intent.action.VIEW" />
// <category android:name="android.intent.category.DEFAULT" />
// <category android:name="android.intent.category.BROWSABLE" />
// <data android:scheme="https" android:host="www.example.com" />
// </intent-filter>
// </activity>
// Test with adb:
// adb shell am start -a android.intent.action.VIEW \
// -d "https://www.example.com/articles/abc-123"
Navigation Animations
// Add enter/exit animations to navigation transitions
composable<Detail>(
enterTransition = {
slideInHorizontally(initialOffsetX = { it })
// slideInHorizontally() is a FUNCTION from compose.animation
// Slides in from the right (full width offset)
},
exitTransition = {
slideOutHorizontally(targetOffsetX = { -it })
// Slides out to the left
},
popEnterTransition = {
slideInHorizontally(initialOffsetX = { -it })
// When popping back — slides in from the left
},
popExitTransition = {
slideOutHorizontally(targetOffsetX = { it })
// When popping — slides out to the right
}
) {
DetailScreen()
}
// Fade transitions
composable<Home>(
enterTransition = { fadeIn(animationSpec = tween(300)) },
// fadeIn() is a FUNCTION from compose.animation
// tween() is a FUNCTION that creates a TweenSpec (duration-based animation)
exitTransition = { fadeOut(animationSpec = tween(300)) }
) {
HomeScreen()
}
// Apply default animations to all destinations via NavHost
NavHost(
navController = navController,
startDestination = Home,
enterTransition = { fadeIn(tween(300)) },
exitTransition = { fadeOut(tween(300)) }
) {
// All destinations inherit these transitions unless overridden
}
Common Mistakes to Avoid
Mistake 1: Creating NavController outside of composition
// ❌ NavController created outside Compose — not remembered
class MainActivity : ComponentActivity() {
val navController = NavHostController(this) // wrong!
}
// ✅ Use rememberNavController inside a composable
@Composable
fun MyApp() {
val navController = rememberNavController()
// Properly remembered and tied to the Composition lifecycle
}
Mistake 2: Passing NavController deep into composables
// ❌ NavController passed everywhere — tight coupling, hard to test
@Composable
fun ArticleCard(article: Article, navController: NavController) {
Card(onClick = { navController.navigate(Detail(article.id)) }) { /* ... */ }
}
// ✅ Pass callback lambdas — composable doesn't know about navigation
@Composable
fun ArticleCard(article: Article, onClick: () -> Unit) {
Card(onClick = onClick) { /* ... */ }
}
// Wire navigation at the screen level
composable<HomeTab> {
HomeScreen(
onArticleClick = { id -> navController.navigate(Detail(id)) }
)
}
Mistake 3: Navigating on every recomposition
// ❌ navigate() called inside composition — fires on every recomposition!
@Composable
fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) {
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle()
if (isLoggedIn) {
navController.navigate(Home) // ❌ called repeatedly!
}
}
// ✅ Use LaunchedEffect for one-time navigation
@Composable
fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) {
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle()
LaunchedEffect(isLoggedIn) {
// LaunchedEffect is a COMPOSABLE FUNCTION for side effects
// Runs when isLoggedIn changes, NOT on every recomposition
if (isLoggedIn) {
navController.navigate(Home) {
popUpTo(Login) { inclusive = true }
}
}
}
}
Mistake 4: Not using launchSingleTop for bottom navigation
// ❌ Tapping the same tab repeatedly stacks duplicates
navController.navigate(HomeTab) // tap tap tap → 3 HomeTab instances!
// ✅ Use launchSingleTop + saveState/restoreState
navController.navigate(HomeTab) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true // prevents duplicates
restoreState = true // restores previous state when returning to tab
}
Mistake 5: Forgetting to handle back navigation for nested graphs
// ❌ After completing checkout, back button goes through all checkout screens
navController.navigate(Home)
// Back: Home → Payment → Shipping → Cart → Home — bad UX!
// ✅ Pop the entire nested graph when navigating away
navController.navigate(Home) {
popUpTo(CheckoutGraph) { inclusive = true }
// Removes all checkout screens from the back stack
// Back: Home → previous screen before checkout — clean!
}
Summary
rememberNavController()is a composable function that creates and remembers a NavHostControllerNavHostis a composable function — the container that displays the current destinationcomposable<T>()is an inline reified extension function on NavGraphBuilder that registers a destination- Type-safe navigation uses
@Serializableobjects/data classes as routes — compile-time safe, refactor-friendly navigate()is a function on NavController that pushes a destination;popBackStack()pops ittoRoute<T>()is an extension function on NavBackStackEntry and SavedStateHandle for type-safe argument extractionnavArgument()is a top-level function for defining typed arguments with string routes- Use
launchSingleTop,saveState, andrestoreStatefor bottom navigation to avoid duplicate tabs navigation<T>()is an inline extension function on NavGraphBuilder for nested graphs- Share ViewModel in a nested graph by passing the parent’s back stack entry to
hiltViewModel(parentEntry) navDeepLink { }is a top-level function for declaring deep link patterns- Never pass NavController deep into composables — use callback lambdas and wire navigation at the screen level
- Use
LaunchedEffectfor navigation triggered by state changes — never navigate inside composition directly - Add enter/exit transitions via composable() parameters or NavHost defaults
Compose Navigation is more Kotlin-native than the Fragment-based Navigation Component — routes are objects, arguments are constructor parameters, and the type system catches mistakes at compile time. Keep NavController at the top level, pass callbacks down, use nested graphs for multi-screen flows, and always handle the back stack properly for a smooth navigation experience.
Happy coding!
Comments (0)