---
name: compose-navigation
description: "Implement type-safe navigation in Jetpack Compose using Navigation 2.8+ with @Serializable routes, NavHost, composable<Route>, nested navigation graphs, deep links, BackHandler, type-safe argument passing, and bottom navigation with NavigationBar integration. Use when building Compose navigation, adding new screens, handling deep links, or implementing bottom nav patterns."
---

# Compose Navigation

Type-safe navigation patterns for Jetpack Compose using Navigation 2.8+
(androidx.navigation:navigation-compose:2.8.x). Covers route definition,
NavHost setup, nested graphs, deep links, bottom navigation, and argument
passing.

## Contents

- [Type-Safe Routes](#type-safe-routes)
- [NavHost Setup](#navhost-setup)
- [Navigating Between Screens](#navigating-between-screens)
- [Arguments (Type-Safe)](#arguments-type-safe)
- [Nested Navigation Graphs](#nested-navigation-graphs)
- [Bottom Navigation](#bottom-navigation)
- [Deep Links](#deep-links)
- [Back Handling](#back-handling)
- [Do's and Don'ts](#dos-and-donts)
- [Troubleshooting](#troubleshooting)
- [Review Checklist](#review-checklist)

## Type-Safe Routes

Define routes as `@Serializable` data classes or objects. This replaces the
old string-based route system.

```kotlin
import kotlinx.serialization.Serializable

// Simple route (no arguments)
@Serializable
object HomeRoute

// Route with required argument
@Serializable
data class DetailRoute(val id: String)

// Route with optional arguments
@Serializable
data class SearchRoute(
    val query: String = "",
    val category: String? = null,
)

// Nested graph route marker
@Serializable
object SettingsGraphRoute
```

## NavHost Setup

```kotlin
@Composable
fun AppNavHost(
    navController: NavHostController = rememberNavController(),
    modifier: Modifier = Modifier,
) {
    NavHost(
        navController = navController,
        startDestination = HomeRoute,
        modifier = modifier,
    ) {
        composable<HomeRoute> {
            HomeScreen(
                onNavigateToDetail = { id ->
                    navController.navigate(DetailRoute(id = id))
                },
                onNavigateToSearch = {
                    navController.navigate(SearchRoute())
                },
            )
        }

        composable<DetailRoute> { backStackEntry ->
            val route = backStackEntry.toRoute<DetailRoute>()
            DetailScreen(
                id = route.id,
                onBack = { navController.popBackStack() },
            )
        }

        composable<SearchRoute> { backStackEntry ->
            val route = backStackEntry.toRoute<SearchRoute>()
            SearchScreen(
                initialQuery = route.query,
                category = route.category,
            )
        }
    }
}
```

## Navigating Between Screens

```kotlin
// Navigate forward
navController.navigate(DetailRoute(id = "flight-123"))

// Navigate with options
navController.navigate(HomeRoute) {
    popUpTo(HomeRoute) { inclusive = true }  // Clear back stack to home
    launchSingleTop = true                   // Avoid duplicate
}

// Pop back
navController.popBackStack()

// Pop to specific destination
navController.popBackStack(HomeRoute, inclusive = false)

// Navigate and clear entire back stack (e.g., after login)
navController.navigate(HomeRoute) {
    popUpTo(0) { inclusive = true }
}
```

## Arguments (Type-Safe)

Arguments are defined directly in the route data class. Navigation 2.8+
serializes them automatically.

```kotlin
@Serializable
data class FlightDetailRoute(
    val flightId: String,
    val origin: String,
    val destination: String,
    val departureTimestamp: Long,
)

// Navigate with arguments
navController.navigate(
    FlightDetailRoute(
        flightId = "TK1",
        origin = "IST",
        destination = "JFK",
        departureTimestamp = System.currentTimeMillis(),
    )
)

// Retrieve arguments in destination
composable<FlightDetailRoute> { backStackEntry ->
    val route = backStackEntry.toRoute<FlightDetailRoute>()
    FlightDetailScreen(
        flightId = route.flightId,
        origin = route.origin,
        destination = route.destination,
        departureTime = Instant.fromEpochMilliseconds(route.departureTimestamp),
    )
}
```

### Supported Argument Types

| Type | Support | Notes |
|------|---------|-------|
| `String` | Native | Serialized directly |
| `Int`, `Long`, `Float`, `Boolean` | Native | Serialized directly |
| `Enum` | Via `@Serializable` | Add `@Serializable` to enum class |
| `List<String>` | Via serialization | Works with kotlinx.serialization |
| Custom objects | Not recommended | Pass ID and fetch from ViewModel instead |

Rule: Pass only primitive identifiers via routes. Let the destination ViewModel
fetch the full object. Do not serialize complex objects into the route.

## Nested Navigation Graphs

Group related screens into nested graphs for better organization and
encapsulation.

```kotlin
// Define nested graph route
@Serializable
object SettingsGraphRoute

@Serializable
object SettingsHomeRoute

@Serializable
object ProfileRoute

@Serializable
object NotificationsRoute

// In NavHost
NavHost(
    navController = navController,
    startDestination = HomeRoute,
) {
    composable<HomeRoute> {
        HomeScreen(
            onNavigateToSettings = {
                navController.navigate(SettingsGraphRoute)
            },
        )
    }

    navigation<SettingsGraphRoute>(startDestination = SettingsHomeRoute) {
        composable<SettingsHomeRoute> {
            SettingsScreen(
                onNavigateToProfile = {
                    navController.navigate(ProfileRoute)
                },
                onNavigateToNotifications = {
                    navController.navigate(NotificationsRoute)
                },
            )
        }

        composable<ProfileRoute> {
            ProfileScreen()
        }

        composable<NotificationsRoute> {
            NotificationsScreen()
        }
    }
}
```

## Bottom Navigation

Integrate `NavigationBar` (Material 3) with NavHost. Each tab maintains its
own back stack.

```kotlin
@Serializable
object HomeRoute

@Serializable
object SearchRoute

@Serializable
object ProfileRoute

enum class TopLevelDestination(
    val label: String,
    val icon: ImageVector,
    val route: Any,
) {
    HOME("Home", Icons.Default.Home, HomeRoute),
    SEARCH("Search", Icons.Default.Search, SearchRoute),
    PROFILE("Profile", Icons.Default.Person, ProfileRoute),
}

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    val currentBackStackEntry by navController.currentBackStackEntryAsState()

    Scaffold(
        bottomBar = {
            NavigationBar {
                TopLevelDestination.entries.forEach { destination ->
                    val isSelected = currentBackStackEntry
                        ?.destination?.hasRoute(destination.route::class) == true

                    NavigationBarItem(
                        selected = isSelected,
                        onClick = {
                            navController.navigate(destination.route) {
                                popUpTo(navController.graph.findStartDestination().id) {
                                    saveState = true
                                }
                                launchSingleTop = true
                                restoreState = true
                            }
                        },
                        icon = {
                            Icon(destination.icon, contentDescription = destination.label)
                        },
                        label = { Text(destination.label) },
                    )
                }
            }
        },
    ) { innerPadding ->
        NavHost(
            navController = navController,
            startDestination = HomeRoute,
            modifier = Modifier.padding(innerPadding),
        ) {
            composable<HomeRoute> { HomeScreen() }
            composable<SearchRoute> { SearchScreen() }
            composable<ProfileRoute> { ProfileScreen() }
        }
    }
}
```

Key points for bottom nav:
- Use `saveState = true` and `restoreState = true` to preserve tab back stacks
- Use `launchSingleTop = true` to prevent duplicate destinations
- Pop up to the graph start destination to avoid stacking tab roots

## Deep Links

### Declare Deep Links in NavHost

```kotlin
composable<DetailRoute>(
    deepLinks = listOf(
        navDeepLink<DetailRoute>(
            basePath = "https://example.com/detail"
        ),
    ),
) { backStackEntry ->
    val route = backStackEntry.toRoute<DetailRoute>()
    DetailScreen(id = route.id)
}
```

### AndroidManifest.xml

```xml
<activity android:name=".MainActivity">
    <intent-filter android:autoVerify="true">
        <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"
            android:pathPrefix="/detail" />
    </intent-filter>
</activity>
```

### Handling Deep Links in Activity

```kotlin
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val navController = rememberNavController()
            AppNavHost(navController = navController)
        }
    }
}
```

The Navigation library handles incoming intents automatically when deep links
are declared in the NavHost graph.

## Back Handling

### Predictive Back Gesture (Android 14+)

Enable in `AndroidManifest.xml`:

```xml
<application android:enableOnBackInvokedCallback="true">
```

### Custom Back Handling

```kotlin
@Composable
fun EditScreen(
    hasUnsavedChanges: Boolean,
    onConfirmDiscard: () -> Unit,
) {
    var showDialog by remember { mutableStateOf(false) }

    BackHandler(enabled = hasUnsavedChanges) {
        showDialog = true
    }

    if (showDialog) {
        AlertDialog(
            onDismissRequest = { showDialog = false },
            title = { Text("Discard changes?") },
            confirmButton = {
                TextButton(onClick = {
                    showDialog = false
                    onConfirmDiscard()
                }) { Text("Discard") }
            },
            dismissButton = {
                TextButton(onClick = { showDialog = false }) { Text("Cancel") }
            },
        )
    }
}
```

## Do's and Don'ts

### Do's
- Use `@Serializable` route classes (Navigation 2.8+) for type safety
- Pass only primitive IDs via routes; fetch full objects in the destination ViewModel
- Use `saveState`/`restoreState` in bottom navigation for tab back stack preservation
- Use `launchSingleTop = true` to avoid duplicate destinations
- Define navigation events as lambdas on composable screens (not navController)
- Keep `navController` ownership at the NavHost level

### Don'ts
- Do not pass `navController` down to child composables -- pass navigation lambdas
- Do not pass complex objects (Parcelable, Serializable) as route arguments
- Do not use string-based routes with Navigation 2.8+ -- use type-safe routes
- Do not create multiple `NavController` instances for the same NavHost
- Do not use `navigate()` inside `LaunchedEffect` without a key guard (causes re-navigation on recomposition)
- Do not call `navigate()` from `composable {}` body directly -- use callbacks or effects

## Troubleshooting

| Problem | Cause | Fix |
|---------|-------|-----|
| `Route not found` crash | Route class missing `@Serializable` | Add `@Serializable` annotation |
| Bottom nav tabs lose state | Missing `saveState`/`restoreState` | Add both options to navigate block |
| Back button exits app from nested screen | Wrong `popUpTo` target | Pop to graph start destination, not root |
| Deep link not triggering | Missing intent-filter or `autoVerify` | Verify manifest and AASA/assetlinks.json |
| Duplicate screens on tab re-tap | Missing `launchSingleTop = true` | Add to navigate options |
| Recomposition triggers navigation | `navigate()` in composable body | Move to callback or `LaunchedEffect` with proper key |
| KSP error on `@Serializable` route | Missing kotlinx-serialization plugin | Add `id("org.jetbrains.kotlin.plugin.serialization")` to build.gradle |
| Arguments not deserialized | Wrong `toRoute<>()` type parameter | Match type parameter to the exact route class |

## Review Checklist

- [ ] All routes are `@Serializable` data classes or objects
- [ ] `NavController` stays at NavHost level; screens receive navigation lambdas
- [ ] Route arguments are primitives or enums only
- [ ] Bottom navigation uses `saveState`/`restoreState`/`launchSingleTop`
- [ ] Deep links are declared in both NavHost and AndroidManifest.xml
- [ ] `BackHandler` is used for screens with unsaved state
- [ ] Predictive back is enabled in manifest for Android 14+
- [ ] Nested graphs use `navigation<>()` with explicit start destination
- [ ] No navigation calls inside composable body without guard
- [ ] kotlinx-serialization plugin is applied in build.gradle.kts
