In a real app, users don’t stay on one screen. They tap an article, go to a detail page, press back, open settings, log in, log out. Navigation is how you move between these screens in Compose. If you’ve used XML navigation with Fragments and nav_graph.xml, forget all of that — Compose navigation is completely different. There’s no XML, no Fragments, no nav_graph file. Everything is in Kotlin, and it’s actually simpler once you understand three things: NavController (who manages the screens), NavHost (where screens are displayed), and Routes (how screens are identified). Let’s build it up step by step.


Before We Start — The Mental Model

// Think of navigation like a STACK OF CARDS on a table:
//
//  ┌─────────────┐
//  │  Detail      │  ← top card (visible, user sees this)
//  ├─────────────┤
//  │  Article List│  ← underneath
//  ├─────────────┤
//  │  Home        │  ← bottom card
//  └─────────────┘
//
// When user opens a new screen → a card is PLACED ON TOP of the stack
// When user presses Back → the top card is REMOVED, card below is now visible
// This stack is called the BACK STACK
//
// In Compose, THREE things manage this:
//
// NavController → the MANAGER (decides which card is on top)
// NavHost       → the TABLE (the area on screen where cards are shown)
// Routes        → the LABELS on each card ("home", "detail/123", "settings")
//
// That's it. Everything else is just details around these three.

Step 1 — Add the Dependencies

// In your build.gradle.kts (app module):

dependencies {
    // This is the ONLY library you need for basic Compose navigation
    implementation("androidx.navigation:navigation-compose:2.8.3")
    // navigation-compose is a LIBRARY that provides NavHost, composable(), etc.

    // If you use Hilt for dependency injection (most production apps do):
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
    // Provides hiltViewModel() — a COMPOSABLE FUNCTION that creates ViewModels with Hilt

    // For type-safe navigation (recommended — we'll cover this later):
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

// Also add the serialization plugin (in your plugins block):
plugins {
    id("org.jetbrains.kotlin.plugin.serialization")
}

Step 2 — Create the NavController

The NavController is the brain of navigation. It knows which screen is currently shown, what’s in the back stack, and how to navigate forward or backward:

@Composable
fun MyApp() {
    val navController = rememberNavController()
    // rememberNavController() is a COMPOSABLE FUNCTION
    //
    // What it does:
    // 1. Creates a NavHostController (a CLASS that manages navigation state)
    // 2. Wraps it in remember {} so it survives recomposition
    // 3. Returns the same instance every time this composable recomposes
    //
    // Think of it like: "create a navigation manager and keep it alive"
    //
    // You call this ONCE at the top level — don't create multiple NavControllers

    // ... NavHost goes here (Step 3)
}

Step 3 — Set Up the NavHost

The NavHost is the area on screen where your screens are displayed. When navigation happens, NavHost swaps out the current screen for the new one:

@Composable
fun MyApp() {
    val navController = rememberNavController()

    NavHost(
        // NavHost is a COMPOSABLE FUNCTION — the container for all your screens
        navController = navController,       // connect it to our NavController
        startDestination = "home"            // which screen shows FIRST
    ) {
        // Inside this block, you REGISTER all your screens
        // This block is NOT @Composable — it's NavGraphBuilder.()
        // NavGraphBuilder is a CLASS that lets you add destinations

        // Screen 1: Home
        composable("home") {
            // composable() is an EXTENSION FUNCTION on NavGraphBuilder
            // "home" is the ROUTE — a string that identifies this screen
            // The lambda IS @Composable — this is your actual screen content
            HomeScreen()
        }

        // Screen 2: Article Detail
        composable("detail/{articleId}") {
            // "detail/{articleId}" is a route WITH A PARAMETER
            // {articleId} is a PLACEHOLDER — replaced with actual value when navigating
            val articleId = it.arguments?.getString("articleId") ?: ""
            // "it" is NavBackStackEntry — a CLASS that holds this destination's info
            DetailScreen(articleId = articleId)
        }

        // Screen 3: Settings
        composable("settings") {
            SettingsScreen()
        }
    }
}

Step 4 — Navigate Between Screens

// NAVIGATING FORWARD — push a new screen onto the stack

Button(onClick = {
    navController.navigate("detail/abc-123")
    // navigate() is a FUNCTION on NavController
    // "detail/abc-123" matches the pattern "detail/{articleId}"
    // So articleId = "abc-123" in the detail screen
}) {
    Text("Open Article")
}

// NAVIGATING BACK — pop the top screen off the stack

Button(onClick = {
    navController.popBackStack()
    // popBackStack() is a FUNCTION on NavController
    // Removes the current screen, previous screen becomes visible
}) {
    Text("Go Back")
}

// Note: the system Back button/gesture AUTOMATICALLY calls popBackStack
// You only need to call it manually for custom back buttons in your UI

Wait — should I pass NavController to every screen?

// ❌ BAD: passing NavController deep into composables
@Composable
fun ArticleCard(article: Article, navController: NavController) {
    Card(onClick = { navController.navigate("detail/${article.id}") }) { /* ... */ }
}
// Problems:
// - ArticleCard now DEPENDS on navigation — can't preview, can't test
// - Every composable knows about NavController — tight coupling

// ✅ GOOD: 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 only:
composable("home") {
    HomeScreen(
        onArticleClick = { articleId ->
            navController.navigate("detail/$articleId")
        },
        onSettingsClick = { navController.navigate("settings") }
    )
}

@Composable
fun HomeScreen(
    onArticleClick: (String) -> Unit,    // callback — no NavController knowledge
    onSettingsClick: () -> Unit
) {
    ArticleCard(article, onClick = { onArticleClick(article.id) })
}

// This is the CORRECT pattern:
// NavController stays at the NavHost level
// Screens receive callbacks
// Child composables know nothing about navigation

Passing Arguments — Three Ways

Way 1: Path arguments (required, in the route)

// Define: composable("detail/{articleId}")
// Navigate: navController.navigate("detail/abc-123")
// Read: backStackEntry.arguments?.getString("articleId")

composable(
    route = "detail/{articleId}",
    arguments = listOf(
        navArgument("articleId") {
            // navArgument() is a TOP-LEVEL FUNCTION — defines argument metadata
            type = NavType.StringType
            // NavType is an ABSTRACT CLASS with predefined types:
            // StringType, IntType, LongType, FloatType, BoolType
        }
    )
) { backStackEntry ->
    val articleId = backStackEntry.arguments?.getString("articleId") ?: ""
    DetailScreen(articleId = articleId)
}

Way 2: Query parameters (optional arguments)

composable(
    route = "articles?category={category}&sort={sort}",
    arguments = listOf(
        navArgument("category") {
            type = NavType.StringType
            defaultValue = "all"    // uses this if not provided
            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, sort)
}

// Navigate:
navController.navigate("articles")                           // uses all defaults
navController.navigate("articles?category=tech")              // category=tech, sort=newest
navController.navigate("articles?category=tech&sort=oldest") // both specified

Way 3: Type-safe navigation with @Serializable (RECOMMENDED)

This is the modern, recommended approach. Instead of error-prone strings, you use Kotlin objects as routes:

// Step 1: Define routes as @Serializable objects or data classes
@Serializable
// @Serializable is an ANNOTATION from kotlinx.serialization
object Home   // no arguments → use object (singleton)

@Serializable
data class Detail(val articleId: String)   // has arguments → use data class

@Serializable
data class ArticleList(
    val category: String = "all",     // optional with default
    val sort: String = "newest"
)

@Serializable
object Settings
// Step 2: Use in NavHost — no strings anywhere!
NavHost(navController = navController, startDestination = Home) {

    composable<Home> {
        // composable<T>() is an INLINE REIFIED EXTENSION FUNCTION on NavGraphBuilder
        // Type parameter T is the route class — type-safe!
        HomeScreen(
            onArticleClick = { id ->
                navController.navigate(Detail(articleId = id))
                // Pass the OBJECT — arguments are constructor parameters
                // Can't forget an argument — compiler error!
                // Can't pass wrong type — compiler error!
            }
        )
    }

    composable<Detail> { backStackEntry ->
        val route = backStackEntry.toRoute<Detail>()
        // toRoute<T>() is an EXTENSION FUNCTION on NavBackStackEntry
        // Deserializes back to your data class — type-safe!
        DetailScreen(articleId = route.articleId)
    }

    composable<Settings> { SettingsScreen() }
}

// Navigate — fully type-safe:
navController.navigate(Detail(articleId = "abc-123"))
navController.navigate(ArticleList(category = "tech"))
navController.navigate(Settings)

// WHY type-safe is better:
// ❌ String: "detial/123" → compiles but CRASHES at runtime (typo!)
// ✅ Type-safe: Detial(articleId = "123") → COMPILE ERROR (class doesn't exist)
// ❌ String: "detail/" → crashes (missing argument)
// ✅ Type-safe: Detail() → COMPILE ERROR (articleId is required)

Reading Arguments in ViewModel

In real apps, the ViewModel reads navigation arguments automatically via SavedStateHandle:

@HiltViewModel
class DetailViewModel @Inject constructor(
    private val repository: ArticleRepository,
    private val savedStateHandle: SavedStateHandle
    // SavedStateHandle is a CLASS — Hilt provides it automatically
    // Navigation arguments are INJECTED into SavedStateHandle by the framework
) : ViewModel() {

    // Type-safe:
    private val route = savedStateHandle.toRoute<Detail>()
    // toRoute<T>() is an EXTENSION FUNCTION on SavedStateHandle
    private val articleId = route.articleId

    // String routes alternative:
    // private val articleId: String = savedStateHandle.get<String>("articleId") ?: ""

    val article: StateFlow<Article?> = repository.getArticleFlow(articleId)
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}

// In the composable — no manual arg passing needed:
composable<Detail> {
    val viewModel: DetailViewModel = hiltViewModel()
    // hiltViewModel() is a COMPOSABLE FUNCTION — creates ViewModel with Hilt
    // SavedStateHandle already has articleId — automatic!
    val article by viewModel.article.collectAsStateWithLifecycle()
    DetailScreen(article = article)
}

Controlling the Back Stack

// Basic forward and back:
navController.navigate(Detail(articleId = "123"))   // push
navController.popBackStack()                         // pop

// ═══ ADVANCED OPTIONS ════════════════════════════════════════════════

// SINGLE TOP — don't create duplicate if already at top
navController.navigate(Home) {
    // Lambda receiver is NavOptionsBuilder — a CLASS
    launchSingleTop = true
    // Without this: Home → Home → Home (stacking duplicates!)
}

// POP UP TO — remove screens while navigating
navController.navigate(Home) {
    popUpTo(Home) {
        // popUpTo() is a FUNCTION on NavOptionsBuilder
        inclusive = true     // also remove Home itself → fresh Home
        saveState = true    // save state of removed screens (bottom nav)
    }
    restoreState = true     // restore tab state when returning
    launchSingleTop = true
}

// CLEAR EVERYTHING — logout
navController.navigate(Login) {
    popUpTo(0) { inclusive = true }   // pop everything → only Login remains
}

Bottom Navigation

// Step 1: Define tab routes
@Serializable object HomeTab
@Serializable object SearchTab
@Serializable object BookmarksTab
@Serializable object ProfileTab

data class BottomNavItem(val route: Any, 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")
)
// Step 2: Build the screen
@Composable
fun MainScreen() {
    val navController = rememberNavController()
    val currentEntry by navController.currentBackStackEntryAsState()
    // currentBackStackEntryAsState() is an EXTENSION FUNCTION on NavController

    Scaffold(
        bottomBar = {
            NavigationBar {
                // NavigationBar is a COMPOSABLE FUNCTION from Material3
                bottomNavItems.forEach { item ->
                    val isSelected = currentEntry?.destination?.route ==
                        item.route::class.qualifiedName

                    NavigationBarItem(
                        selected = isSelected,
                        onClick = {
                            navController.navigate(item.route) {
                                // THE THREE MAGIC OPTIONS for bottom nav:
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true   // save scroll position, text, etc.
                                }
                                launchSingleTop = true  // no duplicate tabs
                                restoreState = true     // restore when switching back
                            }
                        },
                        icon = { Icon(item.icon, contentDescription = item.label) },
                        label = { Text(item.label) }
                    )
                }
            }
        }
    ) { padding ->
        NavHost(navController, startDestination = HomeTab, Modifier.padding(padding)) {
            composable<HomeTab> { HomeScreen(onArticleClick = { navController.navigate(Detail(it)) }) }
            composable<SearchTab> { SearchScreen() }
            composable<BookmarksTab> { BookmarksScreen() }
            composable<ProfileTab> { ProfileScreen() }
            composable<Detail> { DetailScreen(it.toRoute<Detail>().articleId) }
        }
    }
}

Nested Navigation Graphs — Multi-Screen Flows

// Group related screens into a "flow" — like checkout or auth

@Serializable object CheckoutGraph   // the graph (not a screen)
@Serializable object Cart
@Serializable object Shipping
@Serializable object Payment

NavHost(navController, startDestination = HomeTab) {
    composable<HomeTab> { /* ... */ }

    navigation<CheckoutGraph>(startDestination = Cart) {
        // navigation<T>() is an INLINE EXTENSION FUNCTION on NavGraphBuilder
        // Creates a NESTED graph — Cart is first screen when entering
        composable<Cart> { CartScreen(onProceed = { navController.navigate(Shipping) }) }
        composable<Shipping> { ShippingScreen(onProceed = { navController.navigate(Payment) }) }
        composable<Payment> {
            PaymentScreen(onDone = {
                navController.navigate(HomeTab) {
                    popUpTo(CheckoutGraph) { inclusive = true }
                    // Pop ENTIRE checkout flow — clean!
                }
            })
        }
    }
}

// Enter the flow:
navController.navigate(CheckoutGraph)   // goes to Cart

Sharing ViewModel across a flow

// All checkout screens share ONE ViewModel:
composable<Cart> {
    val parentEntry = remember(it) { navController.getBackStackEntry(CheckoutGraph) }
    // getBackStackEntry() is a FUNCTION on NavController
    val viewModel: CheckoutViewModel = hiltViewModel(parentEntry)
    // SAME ViewModel instance for Cart, Shipping, and Payment
    CartScreen(viewModel)
}

composable<Shipping> {
    val parentEntry = remember(it) { navController.getBackStackEntry(CheckoutGraph) }
    val viewModel: CheckoutViewModel = hiltViewModel(parentEntry)   // SAME instance
    ShippingScreen(viewModel)
}
// When checkout is popped → ViewModel is cleared → clean!

Deep Links

// Let external URLs open specific screens in your app

composable<Detail>(
    deepLinks = listOf(
        navDeepLink<Detail>(basePath = "https://www.example.com/articles")
        // navDeepLink<T>() is a TOP-LEVEL FUNCTION
        // Maps Detail properties to URL segments automatically
    )
) { /* ... */ }

// AndroidManifest.xml needs:
// <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>

// Test: adb shell am start -d "https://www.example.com/articles/abc-123"

Navigation Animations

composable<Detail>(
    enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
    // slideInHorizontally is a FUNCTION from compose.animation
    exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
    popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
    popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) { /* ... */ }

// Default animations for ALL destinations:
NavHost(
    navController = navController,
    startDestination = Home,
    enterTransition = { fadeIn(tween(300)) },
    // fadeIn is a FUNCTION, tween() creates a duration-based animation
    exitTransition = { fadeOut(tween(300)) }
) { /* ... */ }

Triggering Navigation from State Changes

// ❌ WRONG — navigates on EVERY recomposition
if (isLoggedIn) { navController.navigate(Home) }   // runs repeatedly!

// ✅ CORRECT — use LaunchedEffect for one-time navigation
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 } }
    }
}

Common Mistakes to Avoid

Mistake 1: Creating NavController in the wrong place

// ❌ Outside Compose or inside a child
class MainActivity { val navController = NavHostController(this) }   // wrong!

// ✅ Once at the root
@Composable fun MyApp() { val navController = rememberNavController() }

Mistake 2: Passing NavController to child composables

// ❌ ArticleCard(article, navController)  — tight coupling
// ✅ ArticleCard(article, onClick = { })  — clean, testable

Mistake 3: Bottom nav without saveState/restoreState

// ❌ navController.navigate(tab)   — loses scroll, creates duplicates
// ✅ navController.navigate(tab) {
//     popUpTo(start) { saveState = true }
//     launchSingleTop = true; restoreState = true
// }

Mistake 4: Navigating inside composition body

// ❌ if (condition) { navController.navigate(Home) }   — every recomposition
// ✅ LaunchedEffect(condition) { if (condition) navController.navigate(Home) }

Mistake 5: Not popping after completing a flow

// ❌ navController.navigate(Home)   — checkout screens still in back stack
// ✅ navController.navigate(Home) { popUpTo(CheckoutGraph) { inclusive = true } }

Summary

  • rememberNavController() (composable function) — creates the navigation manager, call once at root
  • NavHost (composable function) — container that displays the current screen
  • composable<T>() (inline reified extension function) — registers a screen with a type-safe route
  • navigate() (function on NavController) pushes a screen; popBackStack() removes it
  • Use @Serializable objects/data classes for type-safe routes — no strings, no typos, compiler-checked
  • toRoute<T>() (extension function) reads arguments type-safely from NavBackStackEntry or SavedStateHandle
  • Arguments flow into ViewModel via SavedStateHandle automatically — no manual passing
  • Never pass NavController to child composables — use callback lambdas
  • Bottom nav needs launchSingleTop + saveState + restoreState
  • navigation<T>() (inline extension function) creates nested graphs for flows
  • Share ViewModel in a flow via hiltViewModel(parentEntry)
  • Use LaunchedEffect for state-driven navigation — never navigate in composition body
  • navDeepLink (top-level function) maps URLs to screens

Compose Navigation is simpler than Fragment navigation once the mental model clicks: NavController manages the stack, NavHost displays the screen, routes identify each screen. Use type-safe routes with @Serializable, keep NavController at the top level, pass callbacks down, and handle bottom nav with saveState/restoreState. That covers 95% of what you’ll need in production.

Happy coding!