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.
Navigation Options in CMP
There are several navigation libraries for Compose Multiplatform:
| Library | Type-Safe | Maintained By | Notes |
|---|---|---|---|
| navigation-compose | Yes (2.8+) | JetBrains/Google | Official, recommended |
| Voyager | Yes | Community | Popular, mature |
| Decompose | Yes | Community | Lifecycle-aware, powerful |
| Appyx | Yes | Community | Node-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 controllerNavHost— container that swaps screens based on the current routecomposable<RouteType>— registers a screen for a route typetoRoute<RouteType>()— extracts the typed route from the back stack entrynavController.navigate(route)— navigates to a screennavController.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 statelaunchSingleTop = true— prevents duplicate screens if the user taps the same tab twicerestoreState = 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:
Related Tutorials
- KMP Tutorial #4: Compose Multiplatform — shared UI where navigation lives
- KMP Tutorial #10: Shared ViewModel — ViewModels used by screens
- KMP Tutorial #9: Koin — injecting ViewModels into screens
- Compose Tutorial #8: Navigation — Android-only Compose Navigation basics
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.