Navigation
Navigation is a data structure, not a set of activity transitions. Once it is a graph, deep links, back behaviour and state restoration become properties instead of features.
For most of Android's life, navigation meant "start another activity". That works until you need to restore a stack after process death, handle a deep link, or animate a transition between screens that are not activities. A navigation graph solves all three because it makes the destination list explicit.
TL;DR#
- Navigation models screens as destinations in a graph, with routes and arguments.1
- The back stack becomes a list of destinations, not a list of activity instances, so it can be saved and restored.
- Deep links are declared on a destination, so the graph handles them the same way as an internal tap.
- Compose's
NavHosthosts destinations as composables; the activity count drops to one. - The graph is also the modularization boundary: a feature module can expose its destinations without exposing its screens.
Mental model#
Think of a subway map.
Stations are destinations; lines are back stacks; a transfer is a navigation action. The map exists independently of any train. You can start at any station (a deep link), and the route you took is recorded so you can go back.
Where the analogy breaks#
A subway map has no notion of "the same station, but with different arguments". A navigation route takes arguments, and the same destination with a different argument is a different back-stack entry. That is why routes are typed in modern Navigation, and why a string-concatenated route is a bug waiting to happen.
The big picture#
Text description of this diagram
A task-stack simulator that shows how destinations accumulate. Launching activities pushes instances onto task stacks; Back pops them. The same model applies to a navigation graph: each destination pushed by the NavController is an entry, and Back pops the graph. The difference is that graph entries are data — routes plus arguments — so they can be serialised, restored after process death, and reached by a deep link. The launch-mode and flag controls show why singleTask and CLEAR_TOP behave differently from an ordinary push.
The task stack is the system's model; the navigation graph is the app's model of the same idea, one level up.
Deep dive#
A graph#
@Serializable data object Home
@Serializable data class Detail(val noteId: String)
@Serializable data object Settings
@Composable
fun NotesNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
HomeRoute(
onOpenNote = { id -> navController.navigate(Detail(id)) },
onOpenSettings = { navController.navigate(Settings) },
)
}
composable<Detail> { entry ->
val detail: Detail = entry.toRoute()
DetailRoute(noteId = detail.noteId, onBack = navController::popBackStack)
}
composable<Settings> {
SettingsRoute(onBack = navController::popBackStack)
}
}
}Three properties come free from this shape:
- Type-safe arguments.
Detail(noteId)is a Kotlin object; a typo is a compile error, not aNullPointerExceptionat runtime. - Restorable back stack. The entries are serialisable, so the system can save and restore them after process death.
- A single activity.
NavHostswaps composables; there is no activity transition per screen.1
Arguments and saved state#
@HiltViewModel
class DetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: NotesRepository,
) : ViewModel() {
private val noteId: String = checkNotNull(savedStateHandle.toRoute<Detail>().noteId)
val note: StateFlow<Note?> = repository.observeNote(noteId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}The here is populated from the navigation arguments. That is why a ViewModel scoped to a destination survives process death with its arguments intact — the arguments were serialised as part of the back stack.
Deep links#
A deep link is a URI that resolves to a destination. Declared on the graph, it goes through exactly the same code path as an internal navigation call:
<nav-graph android:value="@navigation/notes_nav" />composable<Detail>(
deepLinks = listOf(navDeepLink<Detail>(basePath = "https://notes.example/note")),
) { entry ->
// …
}The manifest entry is what makes the OS aware of the link; without it, the link opens the app but the
graph never sees it.2 And a verified App Link additionally checks assetlinks.json, which is what
makes it open directly instead of via a chooser.2
Back, once more#
The navigation graph participates in predictive back automatically if the app opts in. What the graph adds is the ability to answer "what is the previous destination?" before the gesture completes — which is exactly what the system needs to animate the destination.
// Compose: intercept back for unsaved changes.
BackHandler(enabled = hasUnsavedChanges) {
showDiscardDialog = true
}BackHandler registers with the activity's OnBackPressedDispatcher scoped to the composition, so it
is added and removed with the composable. That is the Compose answer to onBackPressed() overriding,
and it composes correctly with the graph — the innermost enabled handler runs first.
Navigation and multi-pane#
The interesting problem for tablets and foldables: two destinations visible at once. The graph model handles it if you let the layout decide:
@Composable
fun NotesApp(windowSizeClass: WindowSizeClass) {
val navController = rememberNavController()
when (windowSizeClass.widthSizeClass) {
WindowSizeClass.Compact -> {
NavHost(navController, startDestination = Home) { /* single pane */ }
}
else -> {
Row {
NotesListPane(
selectedId = currentDetailId(navController),
onSelect = { navController.navigate(Detail(it)) },
)
NavHost(
navController = navController,
startDestination = DetailPlaceholder,
modifier = Modifier.weight(1f),
) { /* detail pane */ }
}
}
}
}The subtlety: in a two-pane layout, selecting an item should replace the detail entry rather than
stack twenty entries. That is what popUpTo with inclusive is for.
navController.navigate(Detail(id)) {
popUpTo<Detail> { inclusive = true }
launchSingleTop = true
}Transitions and the shared element#
Navigation owns the transition between destinations, and the API matters for perceived speed:
NavHost(
navController = navController,
startDestination = Home,
enterTransition = { fadeIn(tween(220)) + slideInHorizontally { it / 6 } },
exitTransition = { fadeOut(tween(160)) },
) { /* destinations */ }Two rules keep transitions from costing frames:
- Animate cheap properties. Fades and small translations are composited on the render thread; a transition that changes size forces a layout pass on every frame.
- Keep durations short. Material guidance is in the low hundreds of milliseconds. A 500 ms transition feels slow even when it renders at 120 Hz.
Shared elements are the harder case. They require the two destinations to agree on a key, and they only work between destinations that are both composed. In practice, most shared-element animations are better implemented as a single destination that morphs its own layout.
Testing the graph#
@Test
fun backFromDetail_returnsToHome() {
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
navController.navigatorProvider.addNavigator(ComposeNavigator())
composeTestRule.setContent {
NotesNavHost(navController)
}
composeTestRule.onNodeWithText("Shopping").performClick()
composeTestRule.onNodeWithContentDescription("Back").performClick()
assertEquals(Home::class, navController.currentBackStackEntry?.destination?.route?.let { Home::class })
}A TestNavHostController runs the real graph without an activity, which catches the two most common
navigation bugs: a destination that cannot be reached, and a Back that does not go where the graph
claims. It is one of the few tests where asserting on the graph is more useful than asserting on the UI.
Navigation in a modularized app#
A feature module should expose destinations, not screens:
// :feature:notes — public surface
@Serializable data object NotesGraph
fun NavGraphBuilder.notesGraph(navController: NavController) {
composable<NotesGraph> { NotesRoute(navController) }
composable<Detail> { entry -> DetailRoute(entry.toRoute(), navController) }
}
// :app — assembles the graph
NavHost(navController, startDestination = Home) {
notesGraph(navController)
settingsGraph(navController)
}The app module depends on :feature:notes; :feature:notes does not depend on :app and does not
know about any other feature. Navigation actions that cross features are expressed as lambdas passed
in, or as routes the app module wires together. That is the modularization pattern, and navigation is
where it becomes concrete.
What the graph does not do#
A navigation graph does not decide whether the user may go somewhere. Authorisation belongs in the ViewModel or the repository — a destination that checks a permission in its composable checks it after the screen has already been built, which is a visible flash of the wrong thing.
The graph also does not own the argument's lifetime. An argument is serialised into the back-stack
entry, which means it must be small and serialisable. Passing a large object through a route is the
same mistake as putting it in a Bundle, with the same TransactionTooLargeException waiting at the
end of it.
What the graph does own is the identity of a screen. Two entries with the same route but different arguments are different destinations, which is why the typed-route API exists and why string concatenation produces subtle bugs when an argument contains a slash.
Deep links are an API contract#
A deep link is a public interface: once an app publishes https://notes.example/note/{id}, other apps,
search results and users' bookmarks depend on it. That makes three things true which are easy to
forget.
The route is versioned in practice. Changing note/{id} to note/{id}/comments breaks links that were
already shared, so old links must keep resolving to something sensible.
The argument is untrusted. A link can carry any string; a noteId must be validated against the
repository before it is used, and a link to a nonexistent note must show an error rather than a crash.
The link is a security boundary. An exported deep link can be triggered by any app, so it must not perform an action without confirmation — a link that deletes a note on open is a vulnerability.
Common mistakes#
| Mistake | Consequence |
|---|---|
| navigating in a composable body | repeated navigation on every recomposition |
| string routes with concatenated args | no type safety, encoding bugs |
| one activity per screen in a Compose app | loses the graph's restoration |
popBackStack() without checking | pops past the start destination |
| navigating from a ViewModel | the ViewModel now knows about the UI |
The first one is the most common. Navigation is an effect:
// Wrong: navigate() on every recomposition.
@Composable
fun Done(check: Boolean) {
if (check) navController.navigate(Home)
}
// Right: navigate once, as an effect keyed on the condition.
@Composable
fun Done(check: Boolean, onDone: () -> Unit) {
LaunchedEffect(check) {
if (check) onDone()
}
}The Done version with the lambda also keeps the composable testable and previewable, which is the
same state-hoisting argument as everywhere else in this module.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 4 | 1.6 Donut |
|
| 5 | 2.0 Eclair |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 13 | 3.2 Honeycomb |
|
| 14 | 4.0 Ice Cream Sandwich |
|
| 16 | 4.1 Jelly Bean |
|
| 17 | 4.2 Jelly Bean |
|
| 18 | 4.3 Jelly Bean |
|
| 19 | 4.4 KitKat |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 22 | 5.1 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 27 | 8.1 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 32 | 12L |
|
| 33 | 13 Tiramisu |
|
| 34 | 14 Upside Down Cake |
|
| 35 | 15 Vanilla Ice Cream |
|
| 36 | 16 Baklava |
|
| 37 | 17 Cinnamon Bun |
|
Three stages: activities and intents (2008), the (2018), and Compose's NavHost
plus type-safe routes (2021 onward). Each stage moved more of the navigation model out of the OS and
into the app, which is what made deep links, restoration and multi-pane layouts tractable.
Under the hood#
- Task.javathe system's own task stack, which navigation mirrors
- OnBackInvokedDispatcher.javathe predictive-back contract the graph participates in
- Intent.javawhat a deep link ultimately becomes at the OS boundary
Curiosity corner#
- The back stack is saved as a
Bundle. That is why navigation arguments must be serialisable, and why@Serializableroutes are the modern default. launchSingleTopis the graph's version ofsingleTop. Same idea, different layer.- A
NavHostis a composable, which means twoNavHosts in a two-pane layout are two independent back stacks in one activity.
Try it#
adb shell dumpsys activity top | grep -i "nav"
adb shell am start -a android.intent.action.VIEW -d "https://notes.example/note/42"
Check yourself#
Check yourself
Why model a screen as one immutable `UiState` rather than several independent `StateFlow`s?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsApp Linksdeveloper.android.comaccessed 2026-10-27↩
- docsGuide to app architecturedeveloper.android.comaccessed 2026-10-27↩
- docsJetpackdeveloper.android.comaccessed 2026-10-27↩
- docsThinking in Composedeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩