Skip to main content

What This Skill Provides

The Compose Navigation skill covers implementing type-safe navigation using Navigation Compose library. Use this skill when setting up navigation, passing arguments between screens, handling deep links, or structuring multi-screen apps.

When to Use This Skill

  • Setting up navigation in a new Compose app
  • Creating navigation graphs with multiple screens
  • Passing data between destinations
  • Implementing deep links for notifications or web links
  • Building bottom navigation or adaptive navigation UIs
  • Testing navigation flows

Setup

Add the Navigation Compose dependency to your project:
build.gradle.kts
dependencies {
    implementation("androidx.navigation:navigation-compose:2.8.5")
    
    // For type-safe navigation (recommended)
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}

// Enable serialization plugin
plugins {
    kotlin("plugin.serialization") version "2.0.21"
}

Type-Safe Routes

Define routes using @Serializable data classes or objects for compile-time safety.
Route Definitions
import kotlinx.serialization.Serializable

// Simple screen (no arguments)
@Serializable
object Home

// Screen with required argument
@Serializable
data class Profile(val userId: String)

// Screen with optional argument
@Serializable
data class Settings(val section: String? = null)

// Screen with multiple arguments
@Serializable
data class ProductDetail(
    val productId: String,
    val showReviews: Boolean = false
)
Using @Serializable routes provides type safety, reducing runtime errors and making refactoring easier.

Basic Navigation Setup

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

Forward Navigation

Basic Navigation
// Navigate to a new screen
navController.navigate(Profile(userId = "user123"))

// Navigate and replace current screen
navController.navigate(Home) {
    popUpTo<Home> { inclusive = true }
}

Backward Navigation

Back Navigation
// Navigate back
navController.popBackStack()

// Navigate back to specific destination
navController.popBackStack<Home>(inclusive = false)

Advanced Navigation Options

Navigation Options
navController.navigate(Profile(userId = "user123")) {
    // Pop up to destination (clear back stack)
    popUpTo<Home> {
        inclusive = false  // Keep Home in stack
        saveState = true   // Save state of popped screens
    }
    
    // Avoid multiple copies of same destination
    launchSingleTop = true
    
    // Restore state when navigating to this destination
    restoreState = true
}

Bottom Navigation Pattern

Implement bottom navigation with proper state preservation.
Bottom Navigation
@Composable
fun MainScreen() {
    val navController = rememberNavController()
    
    Scaffold(
        bottomBar = {
            NavigationBar {
                val navBackStackEntry by navController.currentBackStackEntryAsState()
                val currentDestination = navBackStackEntry?.destination
                
                NavigationBarItem(
                    icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
                    label = { Text("Home") },
                    selected = currentDestination?.hasRoute<Home>() == true,
                    onClick = {
                        navController.navigate(Home) {
                            popUpTo(navController.graph.findStartDestination().id) {
                                saveState = true
                            }
                            launchSingleTop = true
                            restoreState = true
                        }
                    }
                )
                
                NavigationBarItem(
                    icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
                    label = { Text("Settings") },
                    selected = currentDestination?.hasRoute<Settings>() == true,
                    onClick = {
                        navController.navigate(Settings()) {
                            popUpTo(navController.graph.findStartDestination().id) {
                                saveState = true
                            }
                            launchSingleTop = true
                            restoreState = true
                        }
                    }
                )
            }
        }
    ) { innerPadding ->
        AppNavHost(
            navController = navController,
            modifier = Modifier.padding(innerPadding)
        )
    }
}
The bottom navigation pattern uses saveState and restoreState to preserve the state of each tab when switching between them.

Passing Arguments

Retrieve Arguments in Composable

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

Retrieve Arguments in ViewModel

ViewModel Arguments
@HiltViewModel
class ProfileViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val userRepository: UserRepository
) : ViewModel() {
    
    private val profile: Profile = savedStateHandle.toRoute<Profile>()
    
    val user: StateFlow<User?> = userRepository
        .getUser(profile.userId)
        .stateIn(
            viewModelScope,
            SharingStarted.WhileSubscribed(5000),
            null
        )
}
Pass IDs, Not ObjectsAlways pass only IDs or primitives as navigation arguments. Fetch complex objects in the ViewModel using the ID.
// CORRECT
navController.navigate(Profile(userId = "user123"))

// INCORRECT - Don't pass complex objects
// navController.navigate(Profile(user = complexUserObject))
Deep Link Setup
@Serializable
data class Profile(val userId: String)

composable<Profile>(
    deepLinks = listOf(
        navDeepLink<Profile>(basePath = "https://example.com/profile")
    )
) { backStackEntry ->
    val profile: Profile = backStackEntry.toRoute()
    ProfileScreen(userId = profile.userId)
}

Manifest Configuration

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

Create PendingIntent for Notifications

Notification Deep Link
val context = LocalContext.current
val deepLinkIntent = Intent(
    Intent.ACTION_VIEW,
    "https://example.com/profile/user123".toUri(),
    context,
    MainActivity::class.java
)

val pendingIntent = TaskStackBuilder.create(context).run {
    addNextIntentWithParentStack(deepLinkIntent)
    getPendingIntent(
        0,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )
}

Nested Navigation

Create nested navigation graphs for logical grouping of screens.
Nested Graph
@Serializable object AuthGraph
@Serializable object Login
@Serializable object Register
@Serializable object ForgotPassword

NavHost(navController = navController, startDestination = Home) {
    composable<Home> { HomeScreen() }
    
    // Nested graph for authentication flow
    navigation<AuthGraph>(startDestination = Login) {
        composable<Login> {
            LoginScreen(
                onLoginSuccess = {
                    navController.navigate(Home) {
                        popUpTo<AuthGraph> { inclusive = true }
                    }
                },
                onNavigateToRegister = {
                    navController.navigate(Register)
                }
            )
        }
        
        composable<Register> { RegisterScreen() }
        
        composable<ForgotPassword> { ForgotPasswordScreen() }
    }
}
Nested graphs help organize related screens and allow you to pop entire flows at once.

Adaptive Navigation

Use NavigationSuiteScaffold for responsive navigation that adapts to screen size.
Adaptive Navigation
@Composable
fun AdaptiveApp() {
    val navController = rememberNavController()
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = navBackStackEntry?.destination
    
    NavigationSuiteScaffold(
        navigationSuiteItems = {
            item(
                icon = { Icon(Icons.Default.Home, contentDescription = "Home") },
                label = { Text("Home") },
                selected = currentDestination?.hasRoute<Home>() == true,
                onClick = { navController.navigate(Home) }
            )
            item(
                icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
                label = { Text("Settings") },
                selected = currentDestination?.hasRoute<Settings>() == true,
                onClick = { navController.navigate(Settings()) }
            )
        }
    ) {
        AppNavHost(navController = navController)
    }
}
NavigationSuiteScaffold automatically switches between:
  • Bottom navigation bar on phones
  • Navigation rail on tablets
  • Navigation drawer on large screens

Testing Navigation

Navigation Test
class NavigationTest {
    @get:Rule
    val composeTestRule = createComposeRule()
    
    private lateinit var navController: TestNavHostController
    
    @Before
    fun setup() {
        composeTestRule.setContent {
            navController = TestNavHostController(LocalContext.current)
            navController.navigatorProvider.addNavigator(ComposeNavigator())
            AppNavHost(navController = navController)
        }
    }
    
    @Test
    fun verifyStartDestination() {
        composeTestRule
            .onNodeWithText("Welcome")
            .assertIsDisplayed()
    }
    
    @Test
    fun navigateToProfile_displaysProfileScreen() {
        composeTestRule
            .onNodeWithText("View Profile")
            .performClick()
        
        assertTrue(
            navController.currentBackStackEntry
                ?.destination?.hasRoute<Profile>() == true
        )
    }
}

Best Practices

@Serializable
data class Profile(val userId: String)

navController.navigate(Profile(userId = "123"))
navController.navigate(Profile(userId = user.id))

// Fetch in ViewModel
class ProfileViewModel(savedStateHandle: SavedStateHandle) {
    val userId = savedStateHandle.toRoute<Profile>().userId
    val user = repository.getUser(userId)
}

Critical Rules

DO

  • Use @Serializable routes for type safety
  • Pass only IDs/primitives as arguments
  • Use popUpTo with launchSingleTop for bottom navigation
  • Extract NavHost to a separate composable for testability
  • Use SavedStateHandle.toRoute<T>() in ViewModels

DON’T

  • Pass complex objects as navigation arguments
  • Create NavController inside NavHost
  • Navigate in LaunchedEffect without proper keys
  • Forget FLAG_IMMUTABLE for PendingIntents (Android 12+)
  • Use string-based routes (legacy pattern)

References

Build docs developers (and LLMs) love