Your app has multiple screens. A note list, a note detail, a settings page, a profile screen. Users need to move between them.

On Android, Jetpack Compose uses navigation-compose. On iOS with SwiftUI, you use NavigationStack. Two different APIs for the same concept.

In Compose Multiplatform, you can share navigation too. The navigation-compose library now works on all platforms — Android, iOS, Desktop, and Web.

There are several navigation libraries for Compose Multiplatform:

LibraryType-SafeMaintained ByNotes
navigation-composeYes (2.8+)JetBrains/GoogleOfficial, recommended
VoyagerYesCommunityPopular, mature
DecomposeYesCommunityLifecycle-aware, powerful
AppyxYesCommunityNode-based navigation

This tutorial uses navigation-compose — the official solution. It works the same way as Jetpack Compose Navigation on Android, so if you know that, you already know most of this.

Setup

Dependencies

# gradle/libs.versions.toml
[versions]
navigation = "2.9.0"
serialization = "1.7.3"

[libraries]
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
// shared/build.gradle.kts (or composeApp if not sharing UI)
plugins {
    kotlin("plugin.serialization")
}

sourceSets {
    commonMain.dependencies {
        implementation(libs.navigation.compose)
        implementation(libs.kotlinx.serialization.json)
    }
}

The serialization plugin is needed for type-safe routes.

Step 1: Define Routes

Since navigation-compose 2.8, routes are Kotlin objects — not strings. This gives you compile-time safety:

// shared/src/commonMain/kotlin/navigation/Routes.kt

import kotlinx.serialization.Serializable

@Serializable
object NoteListRoute

@Serializable
data class NoteDetailRoute(val noteId: String)

@Serializable
object CreateNoteRoute

@Serializable
object SettingsRoute

@Serializable
object ProfileRoute

Each route is a @Serializable class or object. Data classes carry parameters (like noteId). Objects are for screens without parameters.

Why this is better than strings:

// OLD way — easy to make typos
navController.navigate("note_detail/$noteId")

// NEW way — compiler checks everything
navController.navigate(NoteDetailRoute(noteId = "abc123"))

Step 2: Create the NavHost

// shared/src/commonMain/kotlin/navigation/AppNavigation.kt

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

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

    NavHost(
        navController = navController,
        startDestination = NoteListRoute
    ) {
        composable<NoteListRoute> {
            NoteListScreen(
                onNoteClick = { noteId ->
                    navController.navigate(NoteDetailRoute(noteId))
                },
                onCreateClick = {
                    navController.navigate(CreateNoteRoute)
                }
            )
        }

        composable<NoteDetailRoute> { backStackEntry ->
            val route = backStackEntry.toRoute<NoteDetailRoute>()
            NoteDetailScreen(
                noteId = route.noteId,
                onBack = { navController.popBackStack() }
            )
        }

        composable<CreateNoteRoute> {
            CreateNoteScreen(
                onSaved = { navController.popBackStack() },
                onBack = { navController.popBackStack() }
            )
        }

        composable<SettingsRoute> {
            SettingsScreen()
        }
    }
}

Key parts:

  • rememberNavController() — creates and remembers the navigation controller
  • NavHost — container that swaps screens based on the current route
  • composable<RouteType> — registers a screen for a route type
  • toRoute<RouteType>() — extracts the typed route from the back stack entry
  • navController.navigate(route) — navigates to a screen
  • navController.popBackStack() — goes back

Step 3: Bottom Navigation

Most apps have a bottom bar with tabs. Here is how to set it up:

// shared/src/commonMain/kotlin/navigation/BottomNavRoutes.kt

import kotlinx.serialization.Serializable

// Top-level destinations (tabs)
@Serializable
object NotesTab

@Serializable
object SearchTab

@Serializable
object ProfileTab
// shared/src/commonMain/kotlin/navigation/AppWithBottomNav.kt

import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController

data class BottomNavItem<T : Any>(
    val label: String,
    val icon: ImageVector,
    val route: T
)

@Composable
fun AppWithBottomNav() {
    val navController = rememberNavController()
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = navBackStackEntry?.destination

    val tabs = listOf(
        BottomNavItem("Notes", Icons.Default.Home, NotesTab),
        BottomNavItem("Search", Icons.Default.Search, SearchTab),
        BottomNavItem("Profile", Icons.Default.Person, ProfileTab),
    )

    Scaffold(
        bottomBar = {
            NavigationBar {
                tabs.forEach { item ->
                    NavigationBarItem(
                        icon = { Icon(item.icon, contentDescription = item.label) },
                        label = { Text(item.label) },
                        selected = currentDestination?.hasRoute(item.route::class) == true,
                        onClick = {
                            navController.navigate(item.route) {
                                // Pop up to the start to avoid building a large stack
                                popUpTo(navController.graph.startDestinationId) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    )
                }
            }
        }
    ) { padding ->
        NavHost(
            navController = navController,
            startDestination = NotesTab,
            modifier = Modifier.padding(padding)
        ) {
            composable<NotesTab> {
                NoteListScreen(
                    onNoteClick = { noteId ->
                        navController.navigate(NoteDetailRoute(noteId))
                    },
                    onCreateClick = {
                        navController.navigate(CreateNoteRoute)
                    }
                )
            }
            composable<SearchTab> {
                SearchScreen()
            }
            composable<ProfileTab> {
                ProfileScreen(
                    onSettingsClick = {
                        navController.navigate(SettingsRoute)
                    }
                )
            }

            // Detail screens (not tabs)
            composable<NoteDetailRoute> { backStackEntry ->
                val route = backStackEntry.toRoute<NoteDetailRoute>()
                NoteDetailScreen(
                    noteId = route.noteId,
                    onBack = { navController.popBackStack() }
                )
            }
            composable<CreateNoteRoute> {
                CreateNoteScreen(
                    onSaved = { navController.popBackStack() },
                    onBack = { navController.popBackStack() }
                )
            }
            composable<SettingsRoute> {
                SettingsScreen()
            }
        }
    }
}

Important navigation options:

  • popUpTo(startDestinationId) { saveState = true } — clears the back stack when switching tabs, but saves state
  • launchSingleTop = true — prevents duplicate screens if the user taps the same tab twice
  • restoreState = true — restores the saved state when switching back to a tab

Step 4: Nested Navigation Graphs

For larger apps, group related screens into nested graphs:

// shared/src/commonMain/kotlin/navigation/NavGraphs.kt

import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.navigation

// Notes feature graph
fun NavGraphBuilder.notesGraph(navController: NavHostController) {
    navigation<NotesTab>(startDestination = NoteListRoute) {
        composable<NoteListRoute> {
            NoteListScreen(
                onNoteClick = { noteId ->
                    navController.navigate(NoteDetailRoute(noteId))
                },
                onCreateClick = {
                    navController.navigate(CreateNoteRoute)
                }
            )
        }
        composable<NoteDetailRoute> { backStackEntry ->
            val route = backStackEntry.toRoute<NoteDetailRoute>()
            NoteDetailScreen(
                noteId = route.noteId,
                onBack = { navController.popBackStack() }
            )
        }
        composable<CreateNoteRoute> {
            CreateNoteScreen(
                onSaved = { navController.popBackStack() },
                onBack = { navController.popBackStack() }
            )
        }
    }
}

// Profile feature graph
fun NavGraphBuilder.profileGraph(navController: NavHostController) {
    navigation<ProfileTab>(startDestination = ProfileRoute) {
        composable<ProfileRoute> {
            ProfileScreen(
                onSettingsClick = { navController.navigate(SettingsRoute) }
            )
        }
        composable<SettingsRoute> {
            SettingsScreen()
        }
    }
}

Then use them in your main NavHost:

NavHost(
    navController = navController,
    startDestination = NotesTab
) {
    notesGraph(navController)
    profileGraph(navController)
    composable<SearchTab> { SearchScreen() }
}

This keeps each feature’s navigation self-contained.

Step 5: Deep Linking

Deep links let users open specific screens from outside the app (like a URL or notification):

composable<NoteDetailRoute>(
    deepLinks = listOf(
        navDeepLink<NoteDetailRoute>(
            basePath = "https://myapp.example.com/notes/{noteId}"
        )
    )
) { backStackEntry ->
    val route = backStackEntry.toRoute<NoteDetailRoute>()
    NoteDetailScreen(
        noteId = route.noteId,
        onBack = { navController.popBackStack() }
    )
}

The URL https://myapp.example.com/notes/abc123 will open NoteDetailScreen with noteId = "abc123". The {noteId} placeholder in the basePath maps to the noteId property of NoteDetailRoute.

On Android, you also need to declare the deep link in AndroidManifest.xml:

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

Screen Design Pattern

Keep screens navigation-agnostic. Pass callbacks instead of the navController:

// GOOD — screen does not know about navigation
@Composable
fun NoteListScreen(
    onNoteClick: (String) -> Unit,
    onCreateClick: () -> Unit,
    viewModel: NoteListViewModel = koinViewModel()
) {
    // Screen content — calls onNoteClick and onCreateClick
}

// BAD — screen depends on navController
@Composable
fun NoteListScreen(navController: NavController) {
    // Tightly coupled to navigation
}

This makes screens reusable and testable. The navigation logic stays in one place (the NavHost).

Common Mistakes

Mistake 1: Navigating with NavController in ViewModel

// BAD — ViewModel should not know about navigation
class NoteListViewModel(
    private val navController: NavController  // Wrong!
) : ViewModel()

// GOOD — use events or callbacks
class NoteListViewModel : ViewModel() {
    private val _navigateToDetail = MutableSharedFlow<String>()
    val navigateToDetail = _navigateToDetail.asSharedFlow()

    fun onNoteClick(noteId: String) {
        viewModelScope.launch {
            _navigateToDetail.emit(noteId)
        }
    }
}

Mistake 2: Not Using launchSingleTop

// BAD — tapping the same tab creates duplicate screens
navController.navigate(NotesTab)

// GOOD — prevents duplicates
navController.navigate(NotesTab) {
    launchSingleTop = true
}

Mistake 3: Using Strings Instead of Type-Safe Routes

// BAD — typos cause runtime crashes
navController.navigate("note_detial/$noteId")  // typo!

// GOOD — compiler catches errors
navController.navigate(NoteDetailRoute(noteId))  // compile-time safe

Source Code

The KMP tutorial project is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Testing in KMP — writing tests for shared code in commonTest, mocking Ktor with MockEngine, and testing SQLDelight queries.

See you there.