Navigation in Kotlin Multiplatform: A Complete Guide for Modern Developers
The landscape of mobile development has evolved dramatically, and Kotlin Multiplatform (KMP) has emerged as a game-changing technology that allows developers to share code across multiple platforms. While Jetpack Navigation 3 has been announced for Android, the multiplatform navigation story requires a different approach. This comprehensive guide explores how to implement robust navigation in your Kotlin Multiplatform projects using current, stable technologies.
Understanding Navigation in Kotlin Multiplatform
Navigation in multiplatform projects presents unique challenges compared to single-platform development. You need to consider platform-specific navigation patterns while maximizing code sharing opportunities. The key is to abstract navigation logic into common code while allowing platform-specific implementations where necessary.
Current State of Multiplatform Navigation
As of 2025, the navigation landscape for Kotlin Multiplatform includes:
- Compose Multiplatform Navigation: The official navigation library from JetBrains
- Custom navigation solutions: Platform-agnostic routing systems
- Hybrid approaches: Combining shared logic with platform-specific implementations
Setting Up Navigation in Kotlin Multiplatform
Project Configuration
Begin by adding the necessary dependencies to your multiplatform project. In your build.gradle.kts file:
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
// Compose Multiplatform Navigation (currently in Beta)
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.9.0-beta02")
// For shared ViewModels
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2")
}
}
val androidMain by getting {
dependencies {
// Android-specific navigation if needed
implementation("androidx.navigation:navigation-compose:2.8.4")
implementation("androidx.activity:activity-compose:1.9.3")
}
}
val iosMain by getting {
dependencies {
// iOS-specific dependencies if needed
}
}
}
}Defining Navigation Structure
Create a shared navigation structure that can be utilized across platforms:
// commonMain/navigation/NavigationDestinations.kt
sealed class Screen(val route: String) {
data object Home : Screen("home")
data object Settings : Screen("settings")
data class Profile(val userId: String = "{userId}") : Screen("profile/$userId") {
fun createRoute(userId: String) = "profile/$userId"
companion object {
const val ROUTE_WITH_ARGS = "profile/{userId}"
}
}
data class ProductDetail(val productId: String = "{productId}") : Screen("product/$productId") {
fun createRoute(productId: String) = "product/$productId"
companion object {
const val ROUTE_WITH_ARGS = "product/{productId}"
}
}
}// Navigation arguments
object NavigationArgs {
const val USER_ID = "userId"
const val PRODUCT_ID = "productId"
}Common Navigation Logic
// commonMain/navigation/NavigationViewModel.kt
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.*class NavigationViewModel : ViewModel() {
private val _navigationState = MutableStateFlow<NavigationState>(NavigationState.Idle)
val navigationState = _navigationState.asStateFlow()
private val _canNavigateBack = MutableStateFlow(false)
val canNavigateBack = _canNavigateBack.asStateFlow()
fun navigateTo(destination: String) {
_navigationState.value = NavigationState.Navigate(destination)
}
fun navigateBack() {
_navigationState.value = NavigationState.NavigateBack
}
fun clearNavigation() {
_navigationState.value = NavigationState.Idle
}
fun updateBackStackState(canGoBack: Boolean) {
_canNavigateBack.value = canGoBack
}
}sealed class NavigationState {
data object Idle : NavigationState()
data class Navigate(val destination: String) : NavigationState()
data object NavigateBack : NavigationState()
}
Platform-Specific Implementation
Android Implementation
// androidMain/navigation/AndroidNavigation.kt
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavHostController
import androidx.navigation.compose.*
import androidx.navigation.navArgument
import androidx.navigation.NavType@Composable
actual fun AppNavigation(navigationViewModel: NavigationViewModel) {
val navController = rememberNavController()
val navigationState by navigationViewModel.navigationState.collectAsState()
// Handle navigation events
NavigationEventHandler(
navigationState = navigationState,
navController = navController,
onEventHandled = navigationViewModel::clearNavigation,
onBackStackChanged = navigationViewModel::updateBackStackState
)
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(Screen.Home.route) {
HomeScreen(
onNavigateToProfile = { userId ->
navigationViewModel.navigateTo(Screen.Profile().createRoute(userId))
},
onNavigateToSettings = {
navigationViewModel.navigateTo(Screen.Settings.route)
}
)
}
composable(
route = Screen.Profile.ROUTE_WITH_ARGS,
arguments = listOf(
navArgument(NavigationArgs.USER_ID) {
type = NavType.StringType
}
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString(NavigationArgs.USER_ID) ?: ""
ProfileScreen(
userId = userId,
onNavigateBack = navigationViewModel::navigateBack
)
}
composable(Screen.Settings.route) {
SettingsScreen(
onNavigateBack = navigationViewModel::navigateBack
)
}
composable(
route = Screen.ProductDetail.ROUTE_WITH_ARGS,
arguments = listOf(
navArgument(NavigationArgs.PRODUCT_ID) {
type = NavType.StringType
}
)
) { backStackEntry ->
val productId = backStackEntry.arguments?.getString(NavigationArgs.PRODUCT_ID) ?: ""
ProductDetailScreen(
productId = productId,
onNavigateBack = navigationViewModel::navigateBack
)
}
}
}@Composable
private fun NavigationEventHandler(
navigationState: NavigationState,
navController: NavHostController,
onEventHandled: () -> Unit,
onBackStackChanged: (Boolean) -> Unit
) {
// Monitor back stack changes
val backStackEntry by navController.currentBackStackEntryAsState()
LaunchedEffect(backStackEntry) {
val canGoBack = navController.previousBackStackEntry != null
onBackStackChanged(canGoBack)
}
// Handle navigation events
LaunchedEffect(navigationState) {
when (navigationState) {
is NavigationState.Navigate -> {
try {
navController.navigate(navigationState.destination) {
// Configure navigation options
launchSingleTop = true
restoreState = true
}
} catch (e: Exception) {
// Handle navigation errors
println("Navigation error: ${e.message}")
}
onEventHandled()
}
is NavigationState.NavigateBack -> {
if (navController.previousBackStackEntry != null) {
navController.popBackStack()
}
onEventHandled()
}
NavigationState.Idle -> {
// Do nothing
}
}
}
}
iOS Implementation (Conceptual)
// iosMain/navigation/IOSNavigation.kt
import androidx.compose.runtime.*@Composable
actual fun AppNavigation(navigationViewModel: NavigationViewModel) {
val navigationState by navigationViewModel.navigationState.collectAsState()
var currentScreen by remember { mutableStateOf<Screen>(Screen.Home) }
// Handle navigation state changes
LaunchedEffect(navigationState) {
when (navigationState) {
is NavigationState.Navigate -> {
// Parse destination and update current screen
currentScreen = parseDestination(navigationState.destination)
navigationViewModel.clearNavigation()
}
is NavigationState.NavigateBack -> {
// Handle back navigation
currentScreen = Screen.Home // Simplified
navigationViewModel.clearNavigation()
}
NavigationState.Idle -> {
// Do nothing
}
}
}
// Render current screen
when (currentScreen) {
is Screen.Home -> {
HomeScreen(
onNavigateToProfile = { userId ->
navigationViewModel.navigateTo(Screen.Profile().createRoute(userId))
},
onNavigateToSettings = {
navigationViewModel.navigateTo(Screen.Settings.route)
}
)
}
is Screen.Profile -> {
ProfileScreen(
userId = extractUserId(currentScreen.route),
onNavigateBack = navigationViewModel::navigateBack
)
}
is Screen.Settings -> {
SettingsScreen(
onNavigateBack = navigationViewModel::navigateBack
)
}
}
}private fun parseDestination(destination: String): Screen {
return when {
destination == Screen.Home.route -> Screen.Home
destination == Screen.Settings.route -> Screen.Settings
destination.startsWith("profile/") -> {
val userId = destination.substringAfter("profile/")
Screen.Profile(userId)
}
else -> Screen.Home
}
}private fun extractUserId(route: String): String {
return route.substringAfter("profile/")
}
Advanced Navigation Patterns
Deep Linking Support
// commonMain/navigation/DeepLinkHandler.kt
class DeepLinkHandler {
fun handleDeepLink(url: String): String? {
return when {
url.contains("/profile/") -> {
val userId = url.substringAfter("/profile/")
Screen.Profile().createRoute(userId)
}
url.contains("/product/") -> {
val productId = url.substringAfter("/product/")
Screen.ProductDetail().createRoute(productId)
}
else -> null
}
}
}Navigation with State Management
// commonMain/navigation/NavigationManager.kt
class NavigationManager(
private val navigationViewModel: NavigationViewModel
) {
fun navigateToUserProfile(userId: String) {
navigationViewModel.navigateTo(Screen.Profile().createRoute(userId))
}
fun navigateToProduct(productId: String) {
navigationViewModel.navigateTo(Screen.ProductDetail().createRoute(productId))
}
fun navigateToSettings() {
navigationViewModel.navigateTo(Screen.Settings.route)
}
fun navigateBack() {
navigationViewModel.navigateBack()
}
fun navigateHome() {
navigationViewModel.navigateTo(Screen.Home.route)
}
}Best Practices
1. Code Organization
Structure your navigation code for maximum reusability:
commonMain/
├── navigation/
│ ├── NavigationDestinations.kt
│ ├── NavigationViewModel.kt
│ ├── NavigationManager.kt
│ └── DeepLinkHandler.kt
├── screens/
│ ├── home/
│ ├── profile/
│ └── settings/
└── ui/
└── components/androidMain/
└── navigation/
└── AndroidNavigation.ktiosMain/
└── navigation/
└── IOSNavigation.kt
2. Error Handling
// commonMain/navigation/NavigationErrorHandler.kt
sealed class NavigationError {
data class RouteNotFound(val route: String) : NavigationError()
data class InvalidParameters(val route: String, val error: String) : NavigationError()
data class PlatformNotSupported(val feature: String) : NavigationError()
}class NavigationErrorHandler {
fun handleError(error: NavigationError) {
when (error) {
is NavigationError.RouteNotFound -> {
println("Route not found: ${error.route}")
// Navigate to fallback screen
}
is NavigationError.InvalidParameters -> {
println("Invalid parameters for route: ${error.route} - ${error.error}")
}
is NavigationError.PlatformNotSupported -> {
println("Feature not supported on this platform: ${error.feature}")
}
}
}
}3. Testing Navigation
// commonTest/navigation/NavigationViewModelTest.kt
import kotlin.test.*
import kotlinx.coroutines.test.runTestclass NavigationViewModelTest {
@Test
fun `should navigate to correct destination`() = runTest {
val viewModel = NavigationViewModel()
viewModel.navigateTo("profile/123")
val state = viewModel.navigationState.value
assertEquals(NavigationState.Navigate("profile/123"), state)
}
@Test
fun `should clear navigation state`() = runTest {
val viewModel = NavigationViewModel()
viewModel.navigateTo("settings")
viewModel.clearNavigation()
val state = viewModel.navigationState.value
assertEquals(NavigationState.Idle, state)
}
@Test
fun `should handle back navigation`() = runTest {
val viewModel = NavigationViewModel()
viewModel.navigateBack()
val state = viewModel.navigationState.value
assertEquals(NavigationState.NavigateBack, state)
}
}Performance Optimization
1. Lazy Screen Loading
@Composable
fun LazyScreen(content: @Composable () -> Unit) {
var isLoaded by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isLoaded = true
}
if (isLoaded) {
content()
} else {
LoadingScreen()
}
}2. Memory Management
// Use lifecycle-aware ViewModels
class ScreenViewModel : ViewModel() {
override fun onCleared() {
super.onCleared()
// Clean up resources
}
}Migration from Legacy Navigation
If you’re migrating from older navigation patterns:
- Gradual Migration: Start with one screen and gradually convert others
- Maintain Compatibility: Keep old navigation working while testing new implementation
- Update Tests: Ensure all navigation tests are updated to work with the new system
- Platform Considerations: Test thoroughly on all target platforms
Future Considerations
As the Kotlin Multiplatform ecosystem evolves:
- Navigation 3 Support: Monitor for official Navigation 3 multiplatform support
- New Platform Targets: Prepare for additional platform support (Web, Desktop)
- Performance Improvements: Stay updated with latest optimization techniques
- Community Solutions: Engage with the community for shared navigation patterns
Conclusion
Navigation in Kotlin Multiplatform requires a thoughtful approach that balances code sharing with platform-specific requirements. By using the current stable navigation libraries and following the patterns outlined in this guide, you can build robust, maintainable navigation systems that work seamlessly across platforms.
Get Haider Ali Khan’s stories in your inbox
Join Medium for free to get updates from this writer.
The key principles to remember:
- Abstract Common Logic: Keep navigation state management in common code
- Platform-Specific Implementation: Allow platforms to handle navigation in their native way
- Type Safety: Use sealed classes and proper parameter handling
- Error Handling: Implement comprehensive error handling for navigation failures
- Testing: Write thorough tests for navigation logic
- Performance: Optimize for smooth navigation experiences
Remember that great navigation is invisible to users — it should feel natural and intuitive. By implementing these techniques thoughtfully, you’ll create applications that provide exceptional user experiences across all target platforms.










