Welcome to Part 2 of the series. You can now build beautiful single-screen apps. But real apps have many screens — a home screen, a detail screen, a settings screen, a profile screen.

Navigation is how you move between them.

In this tutorial, you will learn everything about Compose navigation:

  • Setting up navigation
  • Creating screens and routes
  • Type-safe navigation with Kotlin Serialization
  • Passing data between screens
  • Bottom navigation bar
  • Nested navigation graphs
  • Back stack management
  • Best practices

This is a long tutorial. Take your time.

Setup

Add the navigation dependency to your build.gradle.kts:

dependencies {
    implementation("androidx.navigation:navigation-compose:2.9.7")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

Add the serialization plugin to your project-level build.gradle.kts:

plugins {
    kotlin("plugin.serialization") version "2.2.10"
}

And in your app-level build.gradle.kts:

plugins {
    alias(libs.plugins.kotlin.compose)
    kotlin("plugin.serialization")
}

The Big Picture

Navigation in Compose has three parts:

PartWhat It IsWhat It Does
NavControllerThe navigatorKeeps track of which screen you are on, handles back button
NavHostThe containerShows the current screen, defines all possible screens
RoutesThe addressesEach screen has a unique route (like a URL)

The flow:

User taps "Go to Profile"
NavController.navigate(Profile(id = "123"))
NavHost finds the route for Profile
NavHost shows the ProfileScreen composable
Previous screen is saved on the back stack

Your First Navigation: Two Screens

Let’s start simple — a home screen with a button that navigates to a detail screen.

Step 1: Define Routes

Routes are the “addresses” of your screens. We use @Serializable objects and data classes:

import kotlinx.serialization.Serializable

// Simple screen with no arguments — use object
@Serializable
object Home

// Screen that needs data — use data class
@Serializable
data class Detail(val itemId: String)

Why @Serializable? This is the type-safe way to define routes. The old way used strings like "home" and "detail/{itemId}" — which crashed at runtime if you made a typo. With @Serializable, the compiler catches mistakes before you run the app.

Step 2: Create the NavHost

@Composable
fun AppNavigation() {
    // Create the NavController — it remembers where you are
    val navController = rememberNavController()

    // NavHost shows the current screen
    NavHost(
        navController = navController,
        startDestination = Home // First screen to show
    ) {
        // Home screen route
        composable<Home> {
            HomeScreen(
                onItemClick = { itemId ->
                    navController.navigate(Detail(itemId = itemId))
                }
            )
        }

        // Detail screen route
        composable<Detail> { backStackEntry ->
            // Extract the arguments from the route
            val detail = backStackEntry.toRoute<Detail>()

            DetailScreen(
                itemId = detail.itemId,
                onBack = { navController.popBackStack() }
            )
        }
    }
}

Step 3: Create the Screens

@Composable
fun HomeScreen(onItemClick: (String) -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Text("Home", style = MaterialTheme.typography.headlineLarge)

        Spacer(modifier = Modifier.height(16.dp))

        // List of items — clicking navigates to detail
        val items = listOf("Item A", "Item B", "Item C")
        items.forEach { item ->
            Button(
                onClick = { onItemClick(item) },
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(vertical = 4.dp)
            ) {
                Text("Go to $item")
            }
        }
    }
}

@Composable
fun DetailScreen(itemId: String, onBack: () -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // Back button
        TextButton(onClick = onBack) {
            Text("← Back")
        }

        Spacer(modifier = Modifier.height(16.dp))

        Text("Detail: $itemId", style = MaterialTheme.typography.headlineLarge)
        Text(
            "You are viewing the detail page for $itemId",
            style = MaterialTheme.typography.bodyLarge
        )
    }
}

Step 4: Use It in MainActivity

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                AppNavigation()
            }
        }
    }
}

That’s it. You now have two screens with navigation between them.

Important pattern: Notice that the screens don’t know about navController. They receive callback functions (onItemClick, onBack). This is a best practice — it makes screens reusable and testable.

Passing Arguments Between Screens

Simple Arguments

Define what data the screen needs in the route:

@Serializable
data class Profile(val userId: String)

@Serializable
data class Product(val productId: Int, val category: String)

Navigate with arguments:

// Navigate to a profile
navController.navigate(Profile(userId = "kemalcodes"))

// Navigate to a product
navController.navigate(Product(productId = 42, category = "electronics"))

Read arguments in the destination:

composable<Profile> { backStackEntry ->
    val profile = backStackEntry.toRoute<Profile>()
    ProfileScreen(userId = profile.userId)
}

composable<Product> { backStackEntry ->
    val product = backStackEntry.toRoute<Product>()
    ProductScreen(
        productId = product.productId,
        category = product.category
    )
}

Optional Arguments with Default Values

@Serializable
data class Search(
    val query: String = "",        // Optional — defaults to empty
    val category: String = "all"   // Optional — defaults to "all"
)

// Navigate with some arguments
navController.navigate(Search(query = "compose"))

// Navigate with all defaults
navController.navigate(Search())

What NOT to Pass

Never pass complex objects (like a full User object or a list). Pass only IDs and let the destination screen fetch the data:

// BAD — passing the whole object
@Serializable
data class UserDetail(val name: String, val email: String, val bio: String, ...)

// GOOD — pass only the ID, fetch the rest from the data layer
@Serializable
data class UserDetail(val userId: String)

Why? Because navigation arguments are saved in the back stack. Large objects waste memory and can cause crashes.

Bottom Navigation Bar

Most apps have a bottom bar with 3-5 tabs. Here is how to build one:

Step 1: Define the Tab Routes

@Serializable
object HomeTab

@Serializable
object SearchTab

@Serializable
object ProfileTab

Step 2: Define Tab Items

data class BottomNavItem(
    val label: String,
    val icon: ImageVector,
    val route: Any // The serializable route
)

val bottomNavItems = listOf(
    BottomNavItem("Home", Icons.Default.Home, HomeTab),
    BottomNavItem("Search", Icons.Default.Search, SearchTab),
    BottomNavItem("Profile", Icons.Default.Person, ProfileTab),
)

Step 3: Build the Scaffold with Bottom Bar

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

    // Track which tab is selected
    val currentBackStackEntry by navController.currentBackStackEntryAsState()

    Scaffold(
        bottomBar = {
            NavigationBar {
                bottomNavItems.forEach { item ->
                    NavigationBarItem(
                        selected = currentBackStackEntry?.destination?.hasRoute(
                            item.route::class
                        ) == true,
                        onClick = {
                            navController.navigate(item.route) {
                                // Avoid building up a huge back stack
                                popUpTo(navController.graph.startDestinationId) {
                                    saveState = true
                                }
                                // Don't create multiple copies of the same tab
                                launchSingleTop = true
                                // Restore state when re-selecting a tab
                                restoreState = true
                            }
                        },
                        icon = {
                            Icon(item.icon, contentDescription = item.label)
                        },
                        label = { Text(item.label) }
                    )
                }
            }
        }
    ) { innerPadding ->
        NavHost(
            navController = navController,
            startDestination = HomeTab,
            modifier = Modifier.padding(innerPadding)
        ) {
            composable<HomeTab> { HomeTabScreen() }
            composable<SearchTab> { SearchTabScreen() }
            composable<ProfileTab> { ProfileTabScreen() }
        }
    }
}

The three important flags in navigate:

  • popUpTo — removes screens from the back stack up to the start destination. Without this, pressing back on Tab 3 would go to Tab 2, then Tab 1, then exit. With it, pressing back exits directly.
  • launchSingleTop — prevents creating duplicate screens when tapping the same tab twice.
  • restoreState — remembers the scroll position and state when switching back to a tab.

Step 4: Tab Screens

@Composable
fun HomeTabScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Icon(Icons.Default.Home, contentDescription = "Home", modifier = Modifier.size(48.dp))
        Spacer(modifier = Modifier.height(8.dp))
        Text("Home", style = MaterialTheme.typography.headlineMedium)
    }
}

@Composable
fun SearchTabScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Icon(Icons.Default.Search, contentDescription = "Search", modifier = Modifier.size(48.dp))
        Spacer(modifier = Modifier.height(8.dp))
        Text("Search", style = MaterialTheme.typography.headlineMedium)
    }
}

@Composable
fun ProfileTabScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Icon(Icons.Default.Person, contentDescription = "Profile", modifier = Modifier.size(48.dp))
        Spacer(modifier = Modifier.height(8.dp))
        Text("Profile", style = MaterialTheme.typography.headlineMedium)
    }
}

Nested Navigation

In real apps, each bottom tab might have its own set of screens. For example, the Home tab might have a list screen and a detail screen. This is called nested navigation.

NavHost(
    navController = navController,
    startDestination = HomeTab
) {
    // Home tab has its own navigation flow
    navigation<HomeTab>(startDestination = HomeList) {
        composable<HomeList> {
            HomeListScreen(
                onItemClick = { id ->
                    navController.navigate(HomeDetail(id = id))
                }
            )
        }
        composable<HomeDetail> { backStackEntry ->
            val detail = backStackEntry.toRoute<HomeDetail>()
            HomeDetailScreen(
                id = detail.id,
                onBack = { navController.popBackStack() }
            )
        }
    }

    // Search tab — single screen
    composable<SearchTab> { SearchTabScreen() }

    // Profile tab — single screen
    composable<ProfileTab> { ProfileTabScreen() }
}

The routes for nested navigation:

@Serializable object HomeTab       // The tab route (parent)
@Serializable object HomeList      // First screen in Home tab
@Serializable data class HomeDetail(val id: String)  // Detail screen

@Serializable object SearchTab
@Serializable object ProfileTab

When the user navigates from HomeList → HomeDetail, they stay in the Home tab. The bottom bar stays visible. Pressing back goes from HomeDetail → HomeList, not to a different tab.

Back Stack Management

Going Back

// Go back one screen
navController.popBackStack()

// Go back to a specific screen
navController.popBackStack<HomeList>(inclusive = false)
// inclusive = false → keep HomeList on the stack
// inclusive = true → also remove HomeList

Common pattern: after login, you want to go to Home and make sure the user can’t go back to the login screen.

// Assume you have: @Serializable object Login
navController.navigate(HomeTab) {
    popUpTo<Login> { inclusive = true }  // Remove Login from stack
}

After this, pressing back from Home exits the app instead of going back to Login.

Prevent duplicate screens:

navController.navigate(Profile(userId = "123")) {
    launchSingleTop = true
}

If Profile is already on top of the stack, this does nothing instead of creating a duplicate.

Deep links let users open a specific screen from outside the app (a notification, a web link, etc.):

@Serializable
data class Article(val articleId: String)

composable<Article>(
    deepLinks = listOf(
        navDeepLink<Article>(basePath = "https://kemalcodes.com/article")
    )
) { backStackEntry ->
    val article = backStackEntry.toRoute<Article>()
    ArticleScreen(articleId = article.articleId)
}

Now https://kemalcodes.com/article/compose-tutorial opens the ArticleScreen with articleId = "compose-tutorial".

Don’t forget to add the intent filter to AndroidManifest.xml:

<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="kemalcodes.com" />
    </intent-filter>
</activity>

Best Practices

1. Don’t Pass NavController to Screens

// BAD — screen depends on NavController
@Composable
fun ProfileScreen(navController: NavController) {
    Button(onClick = { navController.navigate(Settings) }) {
        Text("Settings")
    }
}

// GOOD — screen uses callbacks
@Composable
fun ProfileScreen(onSettingsClick: () -> Unit) {
    Button(onClick = onSettingsClick) {
        Text("Settings")
    }
}

Why? Because:

  • Screens are easier to test (no need to create a NavController in tests)
  • Screens are reusable (they don’t know about navigation structure)
  • Navigation logic stays in one place (the NavHost)

2. Keep Routes in One File

// Routes.kt — all routes in one place
@Serializable object Home
@Serializable object Search
@Serializable data class Profile(val userId: String)
@Serializable data class Detail(val itemId: Int)
@Serializable object Settings

This makes it easy to see all screens in your app at a glance.

3. Pass IDs, Not Objects

// BAD — passing whole objects
navController.navigate(UserProfile(name = "Alex", email = "...", bio = "..."))

// GOOD — pass only the ID
navController.navigate(UserProfile(userId = "123"))
// The screen fetches the full user from the database/API

4. Handle Back Button Properly

The system back button works automatically with NavController. But sometimes you need custom behavior:

// In a screen that should confirm before leaving
BackHandler(enabled = hasUnsavedChanges) {
    showConfirmDialog = true
}

5. Use SavedStateHandle in ViewModels

When using ViewModels with navigation arguments:

class ProfileViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    // Automatically reads the argument from the navigation route
    private val profile = savedStateHandle.toRoute<Profile>()
    val userId = profile.userId
}

Common Mistakes

Mistake 1: Creating NavController Inside a Composable

// BAD — creates a new NavController on every recomposition
@Composable
fun MyApp() {
    val navController = NavController(LocalContext.current)
}

// GOOD — remembers the NavController
@Composable
fun MyApp() {
    val navController = rememberNavController()
}

Mistake 2: Not Using popUpTo with Bottom Navigation

Without popUpTo, every tab tap adds to the back stack. After tapping Home → Search → Profile → Home → Search, pressing back goes through all of them. With popUpTo, it exits directly.

Mistake 3: Using String Routes Instead of Type-Safe Routes

// OLD way — typos cause runtime crashes
composable("profile/{userId}") { ... }
navController.navigate("profile/123")

// NEW way — compiler catches errors
composable<Profile> { ... }
navController.navigate(Profile(userId = "123"))

Always use @Serializable routes in 2026. String routes are deprecated.

Quick Reference

ActionCode
Navigate forwardnavController.navigate(Profile(userId = "123"))
Go backnavController.popBackStack()
Go back to specific screennavController.popBackStack<Home>(inclusive = false)
Navigate and clear stacknavController.navigate(Home) { popUpTo<Login> { inclusive = true } }
Prevent duplicatenavController.navigate(Profile) { launchSingleTop = true }
Get current routenavController.currentBackStackEntryAsState()
Read argumentsbackStackEntry.toRoute<Profile>()

Result

Here is what the app looks like when you run the code from this tutorial:

Light ModeDark Mode
Tutorial 8 LightTutorial 8 Dark

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about ViewModel with Compose — how to manage screen logic, survive configuration changes, and connect your UI to data. ViewModel is what makes navigation arguments useful — you pass an ID through navigation, then the ViewModel fetches the actual data.

See you there.