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 NavHostController
  • NavHost is a composable function — the container that displays the current destination
  • composable<T>() is an inline reified extension function on NavGraphBuilder that registers a destination
  • Type-safe navigation uses @Serializable objects/data classes as routes — compile-time safe, refactor-friendly
  • navigate() is a function on NavController that pushes a destination; popBackStack() pops it
  • toRoute<T>() is an extension function on NavBackStackEntry and SavedStateHandle for type-safe argument extraction
  • navArgument() is a top-level function for defining typed arguments with string routes
  • Use launchSingleTop, saveState, and restoreState for 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 LaunchedEffect for 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!