Compose Navigation Graph: Visualize Your Entire App Flow in Android Studio
Compose Navigation Graph: Visualize Your Entire App Flow in Android Studio
Jetpack Compose navigation is imperative code. A NavKey is declared in one file, an entry<Route> { Screen() } lambda wires it to a composable in another, and the actual transitions are backStack.add(...) calls buried inside click handlers scattered across modules. Navigation 3 made all of this type safe, but it did not make it visible: there is still no single place to see the shape of your app, to answer "what can I reach from here?" or "did this refactor change the flow?" Compose Navigation Graph reconstructs that shape statically, at build time, and draws it as an interactive map inside Android Studio.
Compose Navigation Graph is a toolkit of four cooperating pieces: a set of annotations, a KSP processor, a Gradle plugin, and an IntelliJ plugin for Android Studio and IntelliJ IDEA. You annotate your screens, run one Gradle task, and a complete map of your navigation appears in a tool window: every destination as a rendered
@Preview thumbnail, every typed argument, and every transition as an arrow you can follow. It works with Navigation 3, Navigation 2, any other Compose navigation library, and even plain Activities, and it renders every screen device free, with no emulator and no connected device.
In this article, you'll explore the plugin end to end: installing the Gradle plugin and the IDE plugin, annotating your screens with @NavDestination, @NavEdge, @NavPreview, and @NavGraphRoot, generating and reading the graph, authoring new transitions directly from the canvas, browsing the preview gallery, exporting the graph as HTML or PNG, gating pull requests on a committed .nav baseline, and how all of it scales across multi module and Kotlin Multiplatform projects.

The fundamental problem: Navigation you can't see
Consider a typical Navigation 3 setup. You declare a route, wire it to a composable in a NavDisplay, and navigate by mutating the back stack:
@Serializable
data object Feed : NavKey
@Serializable
data class Profile(val userId: String) : NavKey
NavDisplay(backStack = backStack) {
entry<Feed> { FeedScreen(onOpenProfile = { backStack.add(Profile(it)) }) }
entry<Profile> { ProfileScreen(it.userId) }
}
The wiring that connects Feed to FeedScreen lives inside the entry<Feed> { … } lambda. The transition from Feed to Profile lives inside the onOpenProfile callback, as a backStack.add(...) call. Both are function bodies, and a static processor like KSP cannot read function bodies. This is why there is no built in way to see your navigation graph: the information that defines it is locked inside imperative code that only exists at runtime.
Compose Navigation Graph solves this with explicit, refactor safe annotations. You declare the route to composable link and the transitions once, as annotations the processor can read, and the toolkit reconstructs the whole graph from them. The annotations describe your navigation for visualization. They do not change how your app actually navigates, so you keep writing your navigation code exactly as before.
Installing the Gradle plugin
Apply the plugin together with KSP in the build.gradle.kts of the module that holds your screens, your :app or each feature module that declares destinations:
plugins {
id("com.google.devtools.ksp") version "<matching your Kotlin version>"
id("com.github.skydoves.navgraph") version "0.1.1"
}
That is the whole setup. KSP is the one thing the plugin cannot apply for you, because its version is tied to your Kotlin version, so you apply com.google.devtools.ksp explicitly.
A navgraph { } block configures the behavior, with a sensible default for every option:
navgraph {
renderThumbnails.set(true) // device free Layoutlib thumbnails (default true)
variant.set("demoDebug") // pin a flavor; blank auto detects the debug KSP variant
failOnNavChange.set(true) // navCheck fails the build when the graph drifts (default true)
galleryEnabled.set(true) // the preview gallery pipeline (default true)
}
Installing the IDE plugin
The IDE plugin draws the graph the Gradle side produces. Install it from the JetBrains Marketplace: open Android Studio (or IntelliJ IDEA), go to Settings > Plugins > Marketplace, search for Compose Navigation Graph, and click Install.

Once it is installed, open View > Tool Windows > NavGraph Graph. If you see the Graph, Previews, and Author tabs, the plugin is ready. The tool window is anchored on the right side of the IDE by default.
If you would rather not wire any of this up by hand, the repository ships a plugin-agent-guides.md file written for LLM coding agents. Paste it into Claude Code, Cursor, or Gemini CLI as is, and the agent applies the Gradle plugin, annotates your screens, and generates your first graph for you. The rest of this article walks the same steps manually.
Annotating your screens
The graph is reconstructed from four annotations. Navigation 3 NavKey implementors declared in a module are picked up automatically and become nodes with no annotation needed. Everything else is declared explicitly. At minimum, mark the composable that renders a destination with @NavDestination(route) and link a @Preview to it with @NavPreview(route) so the node gets a thumbnail:
@NavGraphRoot
@NavDestination(route = Feed::class)
@NavEdge(to = Profile::class, label = "open profile")
@Composable
fun FeedScreen() { /* … */ }
@NavPreview(route = Feed::class, primary = true)
@Preview
@Composable
fun FeedScreenPreview() {
FeedScreen()
}
Each annotation plays one role in the graph:
@NavDestination\(route = ...\): declares the composable that renders a route. The route class becomes a node in the graph, its serializable properties become the node's typed arguments, and double clicking the node jumps to this composable.@NavEdge\(to = ..., from = ..., label = ...\): declares a transition between two routes, drawn as an arrow. It is repeatable, so one screen can declare several outgoing transitions. Whenfromis omitted, it is inferred from the annotated declaration, andlabelnames the arrow, for example the button that triggers it.@NavGraphRoot: marks the start destination, where the flow begins. The IDE highlights it with a star.@NavPreview\(route = ..., primary = ...\): links a@Previewcomposable to a route so its rendered thumbnail appears on the node. When a route has several previews,primary = truepicks the one shown on the graph.
A route can be any class. It does not need to implement NavKey, so an existing Navigation 2 app, or even plain Activities referenced by @NavEdge, lights up without refactoring.
The node's typed arguments come from the route class, extracted the same way kotlinx.serialization would serialize it: primary constructor properties in order, default valued parameters marked optional, @Transient properties excluded, and @SerialName renames honored.
@Serializable
data class Profile(
val userId: String,
val tab: Tab = Tab.Posts,
) : NavKey
This route produces two argument rows on the Profile node: userId of type String, and tab of the enum type Tab. Because tab has a default value, it is marked optional, while userId is required. Each argument is drawn UML style on the node, so you see at a glance what data every destination expects.
Generating the graph
With the plugin applied and at least one screen annotated, run the entry point task on the module you want to inspect:
./gradlew :app:generateNavGraph
This task runs KSP to extract each module's nodes, typed arguments, and @NavEdge transitions, then renders every @NavPreview screen to a PNG thumbnail. The rendering is device free: it uses Layoutlib, the same engine Android Studio uses to draw @Preview, with a Robolectric fallback for previews Layoutlib cannot handle. No emulator or device is involved. Finally, it aggregates the graphs of the module's dependencies into one combined graph, so an umbrella :app that depends on every feature module gets the whole app's graph, and writes the result to build/navgraph/nav-graph.json with the thumbnails under build/navgraph/thumbs/.
You rarely run this by hand after the first time. The Refresh button in the tool window runs generateNavGraph for you and reloads the result, so you can stay in the IDE.
Reading the graph
Open View > Tool Windows > NavGraph Graph and the Graph tab draws your app's flow as a native, interactive canvas. Each node is a destination, with its rendered thumbnail on top and its typed arguments listed below. Curved arrows show the transitions, and the start destination is marked with an accent border and a star.

The canvas shows the whole app, not one module at a time. Because the Gradle plugin extracts each module's graph and merges them, an edge declared in :feature-feed that points at a screen in :feature-profile is drawn across the module boundary, exactly as it happens at runtime. If your repository holds more than one app, each loads as its own selectable scope, and the tool window remembers the last one you picked.
Three interactions turn the graph into a way to move around your codebase, not just look at it:
- Double click a node to jump to its source, the composable marked with
@NavDestination(route). Find the screen you care about on the map, double click, and you are in the code. - Drag to pan and wheel to zoom, or use the zoom buttons in the corner. The graph auto fits to the viewport on first load.
- The Device combo in the toolbar reframes every thumbnail to a chosen device aspect ratio: Pixels, Galaxies, iPhones, iPads, and more. The default, Auto, keeps each thumbnail at its rendered preview's own size, and your pick becomes the default framing for exports.
Authoring transitions from the canvas
The graph is not read only. You can author transitions on the canvas and the plugin writes the annotations for you. Hover a node to reveal a connector handle on its right edge, then press and drag it onto another node. The plugin inserts the matching @NavEdge(to = ...) into the source's Kotlin through PSI, so the edit is idiomatic and correctly placed source you can review like any other change. It then refreshes the graph and draws the new arrow.

Adding a destination works the same way. The plugin scaffolds a new route class plus its @NavDestination screen stub, and the node appears on the canvas. The same edits are also available from the right click menu:

- Add Transition from Here… lists every screen and lets you pick the target from a searchable list instead of dragging.
- Add Destination… asks for a name, then scaffolds a brand new route class and its annotated screen.
- Wire This Up… appears on an orphan node, a route that is referenced but has no
@NavDestinationyet, and scaffolds its screen composable. - Go to Destination jumps to the node's source, the same as a double click.
This closes the loop between the map and the code. You sketch a flow visually, and your annotations stay the single source of truth.
The preview gallery
The Previews tab renders every @Preview in your project, not just the screens you annotated for the graph, grouped by module and package. It is a living overview of your design system: scan every screen and component at a glance, and double click any preview to jump to its source.

A few things happen automatically. Multipreview meta annotations are expanded, so a composable annotated with a custom @ThemePreviews shows up once per underlying @Preview it declares. @PreviewParameter providers are honored, so previews driven by sample data render with that data. And every module contributes, merged into one view with a section header per module.
The gallery runs on the same device free Layoutlib pipeline as the graph thumbnails. The tab's Refresh button runs generatePreviewGallery for you, or you can run it from the command line:
./gradlew :app:generatePreviewGallery
These gallery tasks are on demand only. They never run as part of generateNavGraph or check, so they cost nothing unless you ask for them.
Exporting the graph
Everything the tool window shows can leave the IDE as a standalone artifact, ready to drop into a pull request, a design review, or a team wiki. The Export action in the toolbar saves the graph as a single PNG of the whole canvas, or as an interactive HTML page you can pan, zoom, and filter in a browser. The same exports are available as Gradle tasks:
./gradlew :app:exportNavGraphHtml # a standalone interactive HTML canvas
./gradlew :app:exportNavGraphImage # a single PNG of the whole graph
The HTML export is a self contained page. You can pan and zoom the flow map, filter routes, and read a screens table with every argument, transition, and source location.

The preview gallery exports the same way, as a browsable HTML gallery or a single PNG contact sheet:
./gradlew :app:exportPreviewGalleryHtml # a standalone HTML gallery
./gradlew :app:exportPreviewGalleryImage # a single PNG contact sheet

Validating navigation in pull requests
A navigation change is otherwise invisible in review. A new destination, a changed argument type, an added or removed transition: none of these stand out in a diff full of UI code. Compose Navigation Graph borrows the idea behind apiDump and apiCheck and applies it to navigation. You commit a .nav baseline, and a git diff then shows exactly how the navigation changed.
Two tasks manage the baseline:
./gradlew :app:navDump # write or update the committed baseline (app/nav/app.nav)
./gradlew :app:navCheck # verify it; wired into check, so it fails on drift
Both read the statically extracted graph directly and never render thumbnails, so they are fast enough to run on every check and every CI build. The committed baseline is intentionally human readable, so a pull request diff reads like a description of your app's flow:
dest Article args=(id: String, section: Section = …, query: String? = …)
dest Home start
dest Profile args=(userId: String)
edge Feed -> Profile
edge Home -> Feed
edge Profile -> Article "Open article"
edge Settings -> Home "home"
When the graph drifts from the baseline, navCheck prints a removed and added diff and fails the build, so the change has to be reviewed and the baseline deliberately updated with navDump:
navgraph: navigation graph changed — app/nav/app.nav is out of date:
- edge Profile -> Settings
+ dest Onboarding
+ edge Home -> Onboarding "first run"
Run :app:navDump to update the baseline, then review the diff.
Two settings tune this for a real team: strict on CI but warning only locally, and gradual adoption across modules.
navgraph {
failOnNavChange.set(System.getenv("CI") == "true") // fail on CI, warn locally
allowMissingBaseline.set(true) // skip modules without a baseline yet
}
Multi module and Kotlin Multiplatform
The plugin is built for real project shapes. In a multi module app, each module's graph is extracted independently and merged into one canvas, even when the :app module itself is not annotated. You see the whole app, with cross module edges drawn across the boundaries.
Compose Navigation Graph also works on Kotlin Multiplatform out of the box. The annotations live in commonMain, published for Android, JVM, iOS, JS, and wasmJs, and the Gradle plugin detects your module shape and wires the right KSP pass automatically. On a KMP module with an Android target, the graph is extracted from the Android compilation and every screen still gets a thumbnail, reusing the consuming app's merged Compose resources. On a KMP module without Android, extraction produces a structure only graph: nodes, typed arguments, and transitions, without thumbnails.
The repository shows this on a real app. The samples/sample-kotlinconf module applies the plugin to JetBrains' Compose Multiplatform KotlinConf app and renders its complete graph: 26 screens, 36 transitions, every thumbnail included.
Beyond Navigation 3: Activities, Navigation 2, and other libraries
The graph never reads your navigation library. It reads the four annotations, and a route is just a class those annotations point at. Navigation 3 NavKey types are the one case recognized automatically, because the processor knows the NavKey marker. Every other class becomes a node by being named in a route, from, or to argument, whether or not it implements NavKey. That single rule is what lets one graph span Navigation 3, plain Activities, Navigation 2, and libraries like Voyager or Decompose at the same time.
Navigation 3 is the case with the least to declare. A @Serializable sealed interface : NavKey with data object and data class implementers becomes a set of nodes automatically, and because these are NavKey types, each implementer's serializable properties are mined as the node's typed arguments:
@Serializable
sealed interface AppKey : NavKey
@NavGraphRoot
@Serializable
data object Home : AppKey
@Serializable
data class Profile(val userId: String) : AppKey
Home and Profile are on the canvas the moment you declare them, and Profile shows a userId: String argument row. You still add @NavDestination to give a node its double click target, @NavEdge to draw a transition, and @NavPreview to attach a thumbnail, but the nodes themselves exist for free.
A plain Activity joins the same graph without implementing anything. In the sample app, ProfileScreen reaches a second ComponentActivity, and a single @NavEdge draws that hop across the boundary:
@NavEdge(to = Article::class, label = "Test Label")
@NavEdge(to = Settings::class)
@NavEdge(to = ProfileDetailActivity::class, label = "View Detail")
@NavDestination(route = Profile::class)
@Composable
fun ProfileScreen(userId: String, /* … */) { /* … */ }
To give that Activity node its own double click target and thumbnail, you annotate the composable it hosts, exactly as you would any screen:
@NavDestination(route = ProfileDetailActivity::class)
@Composable
fun ProfileDetailScreen() { /* … */ }
@NavPreview(route = ProfileDetailActivity::class, primary = true)
@Preview
@Composable
fun ProfileDetailPreview() {
ProfileDetailScreen()
}
ProfileDetailActivity is a node because three annotations name it, not because it implements NavKey. A node that isn't a NavKey carries no typed arguments, since the processor does not mine an arbitrary class's fields, but it renders its thumbnail, links to source, and sits on the map like any other destination.
Navigation 2 routes, Voyager screens, Decompose components, or fragments wire in through the same door. The processor does not parse NavController graphs, composable("route") builders, or any library's runtime wiring. You declare the annotated model alongside your real navigation code, once per project, and point the annotations at the classes you already use as routes:
@Serializable
data class SearchResults(val query: String)
@NavEdge(to = SearchResults::class, label = "search")
@NavDestination(route = HomeRoute::class)
@Composable
fun HomeScreen() { /* … */ }
Here HomeRoute and SearchResults are ordinary Navigation 2 routes, not NavKey types. They appear as nodes joined by the search edge, you attach a thumbnail to either one with @NavPreview the same way you would a Navigation 3 screen, and the only thing they do not get is the typed argument rows, which are mined from NavKey routes. Your navigation code stays untouched: you keep calling navController.navigate(...), and the annotations are the parallel, readable description the toolkit draws. One detail to keep in mind: a class that does not implement NavKey becomes a node only when an annotation references it, so an unannotated, unreferenced route does not appear on its own.
This is why a mixed app draws as one connected picture. A NavKey destination, a Navigation 2 route, and an Activity can share the same graph, joined by edges that cross between them, because to the processor they are all just classes that a node points at.
What's next: A live back stack with Compose HotSwan
Everything so far happens at build time. The graph is extracted statically, the thumbnails are rendered without a device, and double clicking a node jumps to its source code. The map describes your app, but it does not yet reach into the app while it runs.
That is the direction worth looking at next, and it pairs naturally with Compose HotSwan, the author's Jetpack Compose hot reload plugin for Android. HotSwan hot reloads your Compose code on a real device or emulator in seconds: UI changes appear instantly while the app keeps its navigation and runtime state, with no reinstall and no restart.

Compose Navigation Graph knows the shape of your navigation, and HotSwan keeps a live channel into a running app. Put the two together and the static map turns into a control surface for the running app's back stack. A few directions this opens up:
- Jump to a screen by clicking the map. Click a node in the graph and the running app navigates straight to that destination, instead of tapping through the UI to reach it.
- Restore a back stack in one click. Save a back stack you care about, a deep three screen flow with its arguments, and bring the running app back to that exact state in a single click, with no manual navigation.
- Rearrange the back stack in real time. Push, pop, or reorder destinations from the map and let the running app follow, so reproducing a bug report or a specific state becomes a matter of arranging nodes instead of retracing steps by hand.
None of this ships in the plugin today. The graph is still a build time map, and this runtime bridge is a direction rather than a feature you can reach for right now. But the pieces line up: a visual, typed, whole app graph on one side, and a runtime that updates a running app live on the other. Closing the distance between the map and the live app is where this is headed.
Putting it together
The pieces form a single loop around your navigation. During development, you annotate screens as you build them and keep the NavGraph Graph tool window open to see the flow take shape, double clicking nodes to move around the code and dragging connectors to add transitions without leaving the canvas. When you need to share the picture, you export the graph as HTML or PNG and attach it to a design review or a pull request. And to keep the flow honest over time, you commit a .nav baseline and let navCheck fail any pull request that changes navigation without updating it, so every destination and transition change is reviewed on purpose.
Because the extraction is static and the thumbnails are rendered with Layoutlib, all of it runs at build time, with no emulator and no running app, and it behaves the same across multi module and Kotlin Multiplatform projects. The annotations are a map of your app, not a change to how it navigates. You keep your Navigation 3, Navigation 2, or Activity code as it is, and add annotations where you want the picture to be richer: a thumbnail here, an explicit edge there, and the map fills in.
A light note of thanks is due here. This project is free and open source, released with the support of CodeRabbit, an AI powered code review platform that reviews pull requests in context, directly in your workflow and IDE. Their sponsorship is what made open sourcing it possible, and it is much appreciated.
Compose Navigation Graph is available now. Install the IDE plugin from the JetBrains Marketplace, apply the
com.github.skydoves.navgraph Gradle plugin, and run ./gradlew :app:generateNavGraph to see your first graph. The full documentation, covering the annotations, the navgraph { } DSL, the .nav baseline, the exports, and the IDE plugin, lives at skydoves.github.io/compose-nav-graph.
As always, happy coding!
— Jaewoong (skydoves)

