DEV Community

Aleyn
Aleyn

Posted on

Adding Cross-Module Routing to Navigation 3 with KSP: nav3-helper Design and Usage

One important change in Compose Navigation 3 is that navigation state is back in the developer's hands.

That may not sound like a big deal at first, but it feels very natural once you start building with it. The page stack can be managed as regular state, each page can be represented as a NavKey, and NavDisplay renders the current page from that state. The whole model is much closer to Compose's state-driven style than before.

However, once a project becomes modular, another question quickly appears:

Module A wants to navigate to a page in module B, but module A should not directly depend on module B's page class. What should we do?

If we keep writing string routes, parameter parsing, page registries, and entry dispatching by hand, it soon turns into repetitive work. Once a page has more parameters, type safety and default value handling also become annoying.

So I built this library: nav3-helper.

As the name suggests, it is only a helper library on top of the official Navigation 3 APIs. All core functionality still depends on official Navigation 3.

That was November 2025. At that time, Navigation 3 had not released a stable version yet, so I kept waiting for the official stable version before doing the final polish. During that period, I was also working on some KMP-related things. Later, Navigation 3 gained multiplatform support, so this library naturally had to support it as well. Then the end of the year got busy, and the release was delayed until now.

We are in the AI era now, so I also let AI help with some parts of this library. Some generated code, all English comments, the English README, and the string parsing logic were all assisted by AI.

Back to the point. The goals of nav3-helper are simple:

  • Keep Navigation 3's state-first navigation model
  • Generate Destination and Registry code automatically with KSP
  • Support cross-module navigation through fixed route keys
  • Restore lightweight parameters from URL query strings
  • Support page result callbacks
  • Support Kotlin Multiplatform / Compose Multiplatform scenarios

At this point, you may ask: does this just bring us back to Navigation 2-style string routes? Not really. Strings are only used for cross-module contracts. In the end, the route is still resolved into the corresponding Destination for navigation.

Final Usage First

Assume we have a detail page:

@Screen(route = "https://www.app.cn/compose-app/detail")
@Composable
fun DetailScreen(
    detailId: Int = 0,
    name: String? = null
) {
    Text("DetailScreen detailId=$detailId name=$name")
}
Enter fullscreen mode Exit fullscreen mode

Add @Screen, and KSP will generate the corresponding Destination for it, for example:

DetailScreenDestination(
    detailId = 110,
    name = "Aleyn"
)
Enter fullscreen mode Exit fullscreen mode

If you are navigating inside the same navigation Host, you can use the generated Destination directly:

val backStack = LocalNavBackStackState.current
backStack.navigate(
    DetailScreenDestination(
        detailId = 110,
        name = "Aleyn"
    )
)
Enter fullscreen mode Exit fullscreen mode

Using NavCenter directly is also fine:

NavCenter.navigate(
    DetailScreenDestination(
        detailId = 110,
        name = "Aleyn"
    )
)
Enter fullscreen mode Exit fullscreen mode

For cross-module navigation, use the unified route entry:

NavCenter.navigate(
    "https://www.app.cn/compose-app/detail?detailId=110&name=Aleyn"
)
Enter fullscreen mode Exit fullscreen mode

In other words, the library separates two needs:

  • Local navigation: use generated Destinations directly, which is more type-safe and direct
  • Cross-module navigation: use stable route keys, so modules do not need to reference each other's page classes directly

Why Route Keys Are Needed

In a modular project, a page is usually more than just a function. It is also a public contract.

For example, the child_first module wants to navigate to the child_second module:

NavCenter.navigate("https://www.app.cn/child-second/main")
Enter fullscreen mode Exit fullscreen mode

The caller only needs to know this route key. It does not need to depend on SecondScreenDestination, and it does not need to know where SecondScreen is implemented.

The target page is declared like this:

@Screen(route = "https://www.app.cn/child-second/main", start = true)
@Composable
fun SecondScreen() {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The route key itself does not enforce a specific protocol format. All of the following styles are valid:

https://www.app.cn/user/detail
app://user/detail
user/detail
Enter fullscreen mode Exit fullscreen mode

My recommendation is to treat routes as module-level APIs in real projects. They should not be tightly coupled to function names, and they should not change frequently.

Installation

Android Project

For an Android module, add:

dependencies {
    implementation("io.github.aleyn97:navigation3-helper:1.0.0")
    ksp("io.github.aleyn97:nav3-ksp-compiler:1.0.0")
}
Enter fullscreen mode Exit fullscreen mode

Plugin configuration:

plugins {
    id("com.android.application") // or com.android.library
    kotlin("android")
    id("com.google.devtools.ksp")
}
Enter fullscreen mode Exit fullscreen mode

Multiplatform Project

For a Kotlin Multiplatform project, add the KSP compiler to common metadata:

dependencies {
    implementation("io.github.aleyn97:navigation3-helper:1.0.0")
    add("kspCommonMainMetadata", "io.github.aleyn97:nav3-ksp-compiler:1.0.0")
}
Enter fullscreen mode Exit fullscreen mode

If the project also uses platform-specific KSP tasks, you can add:

dependencies {
    add("kspAndroid", "io.github.aleyn97:nav3-ksp-compiler:1.0.0")
    add("kspIosX64", "io.github.aleyn97:nav3-ksp-compiler:1.0.0")
    add("kspIosArm64", "io.github.aleyn97:nav3-ksp-compiler:1.0.0")
    add("kspIosSimulatorArm64", "io.github.aleyn97:nav3-ksp-compiler:1.0.0")
}
Enter fullscreen mode Exit fullscreen mode

Modules that declare @Screen pages need to apply KSP:

plugins {
    kotlin("multiplatform")
    id("com.google.devtools.ksp")
}
Enter fullscreen mode Exit fullscreen mode

If any screen parameter uses an @Serializable type, also apply the Kotlin serialization plugin:

plugins {
    kotlin("plugin.serialization")
}
Enter fullscreen mode Exit fullscreen mode

In some KMP projects, you also need to add the generated commonMain KSP directory back to the source set:

kotlin {
    sourceSets {
        commonMain {
            kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Initializing Registries

KSP generates a Registry for each module. During app startup, register these Registries:

fun initNavigation() {
    loadNavRegistry(
        ComposeAppRegistry,
        ChildFirstRegistry,
        ChildSecondRegistry
    )
}
Enter fullscreen mode Exit fullscreen mode

Then mount the navigation Host at the root of your Compose UI:

@Composable
fun App() {
    NavDisplayHelper(ComposeAppRegistry.defaultStartScreen)
}
Enter fullscreen mode Exit fullscreen mode

NavDisplayHelper does several things:

  • Creates and owns NavBackStackState
  • Provides LocalNavBackStackState to pages
  • Attaches the current Host to NavCenter
  • Calls the official Navigation 3 NavDisplay internally
  • Adds saveable state and ViewModelStore entry decorators by default

If you want full control over NavDisplay, you can skip the helper and pass backStack and entryProvider yourself:

val backStack = rememberHelperBackStack(
    startRoute = ComposeAppRegistry.defaultStartScreen,
    navRegistrySet = setOf(ComposeAppRegistry)
)

NavDisplay(
    backStack = backStack.navBackStack,
    onBack = { backStack.goBack() },
    entryProvider = getEntryProvider(setOf(ComposeAppRegistry))
)
Enter fullscreen mode Exit fullscreen mode

How Page Parameters Are Handled

For normal local navigation, parameters are just constructor parameters of the generated Destination:

backStack.navigate(
    DetailScreenDestination(
        detailId = 110,
        name = "Aleyn"
    )
)
Enter fullscreen mode Exit fullscreen mode

For route navigation, dynamic values are passed through the query string:

NavCenter.navigate(
    "https://www.app.cn/compose-app/detail?detailId=110&name=Aleyn"
)
Enter fullscreen mode Exit fullscreen mode

Internally, the library splits a route into two parts:

  • route key: the page identity, for example https://www.app.cn/compose-app/detail
  • query parameters: runtime parameters, for example detailId=110&name=Aleyn

When matching a page, only the route key is used. Query parameters are not part of page identity.

Currently, query parameters support:

  • String
  • Kotlin primitive types
  • nullable types
  • default values
  • @Serializable objects

For example, a page may have a default parameter:

@Screen(route = "app://user/detail")
@Composable
fun UserDetailScreen(
    id: Long,
    tab: String = "post"
) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

At runtime, you can navigate with:

NavCenter.navigate("app://user/detail?id=123&tab=comment")
Enter fullscreen mode Exit fullscreen mode

If tab is not provided, the default value "post" will be used.

If a required parameter is missing, a primitive value fails to parse, or an @Serializable JSON payload fails to parse, this route resolution fails and no invalid page object is generated.

Passing Serializable Parameters

Some parameters are objects, such as user information or filter conditions. You can use @Serializable:

@Serializable
data class UserInfo(
    val userId: String,
    val avatarUrl: String,
    val nickName: String
)

@Screen(route = "https://www.app.cn/compose-app/me")
@Composable
fun MeScreen(userInfo: UserInfo) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

When navigating, encode the value with serializeRouteQueryValue and put it into the query string:

val userInfo = UserInfo(
    userId = "66666",
    avatarUrl = "https://www.app.cn/image/avatar.png",
    nickName = "Aleyn"
)

val userInfoParam = serializeRouteQueryValue(userInfo)

NavCenter.navigate(
    "https://www.app.cn/compose-app/me?userInfo=$userInfoParam"
)
Enter fullscreen mode Exit fullscreen mode

One thing to keep in mind: URL query strings are better suited for lightweight, public, recoverable route parameters. Complex objects, large payloads, and sensitive business state should not be put into URLs. A better approach is to pass an id and load the data inside the page.

Page Result Callbacks

Besides navigation, pages often need to return results.

For example, navigate from FirstHomeScreen to SecondScreen, and return a string when the second page closes:

@Screen(route = "https://www.app.cn/child-second/main", start = true)
@Composable
fun SecondScreen() {
    val backStack = LocalNavBackStackState.current

    Button(onClick = {
        backStack.setResult("SecondScreen Back")
        backStack.goBack()
    }) {
        Text("Back And Return Value")
    }
}
Enter fullscreen mode Exit fullscreen mode

The previous page consumes the result:

@Screen(route = "https://www.app.cn/child-first/main", start = true)
@Composable
fun FirstHomeScreen() {
    val backStack = LocalNavBackStackState.current
    var resultData by remember { mutableStateOf("") }

    backStack.consumeResultEffect<String> {
        resultData = it.orEmpty()
    }

    // ...
}
Enter fullscreen mode Exit fullscreen mode

The result APIs include:

setResult(...)
peekResult(...)
consumeResult(...)
consumeResultEffect(...)
hasResult(...)
clearResult(...)
Enter fullscreen mode Exit fullscreen mode

By default, the result key uses the type itself. If a flow has multiple results of the same type, you can also pass a custom key.

What KSP Generates

The KSP compiler in this library mainly generates two kinds of code.

The first kind is page Destinations.

For a page like this:

@Screen(route = "app://user/detail")
@Composable
fun UserDetailScreen(
    id: Long,
    tab: String = "post"
) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

It generates a NavScreen similar to this:

@Serializable
data class UserDetailScreenDestination(
    val id: Long,
    val tab: String = "post"
) : NavScreen
Enter fullscreen mode Exit fullscreen mode

If a page has no parameters, it generates an object to avoid unnecessary object creation and boilerplate:

@Serializable
data object HomeScreenDestination : NavScreen
Enter fullscreen mode Exit fullscreen mode

The second kind is module Registries.

A Registry contains:

  • The route set of the current module
  • The default start destination
  • Navigation 3's entryProvider
  • The route-to-Destination resolution logic
  • The serializersModule required for NavKey polymorphic serialization

In other words, business code only declares pages:

@Screen(route = "app://home", start = true)
@Composable
fun HomeScreen() {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Page registration, parameter parsing, and entry dispatching are all handed over to KSP.

Route Rules

To keep cross-module navigation stable, routes should follow a few rules:

  • Route keys should be globally unique
  • @Screen(route = ...) should only contain the fixed page identity, not query parameters
  • Do not use path templates such as user/{id}
  • Pass dynamic values through runtime query parameters
  • Keep query parameter names aligned with composable parameter names
  • Do not tightly couple route keys to function names

The library also applies some normalization internally:

  • Query strings and fragments are not part of page identity
  • Empty path segments are ignored, so a trailing slash does not affect matching
  • Scheme and authority are converted to lowercase
  • If the same query key appears multiple times, the last value wins

In addition, duplicate routes are checked when Registries are registered. If different modules declare the same route key, it fails fast instead of randomly navigating to one of them at runtime.

The Boundary of NavCenter

NavCenter is the global route entry, but it does not own navigation state.

It is only responsible for:

  • Holding global Registries
  • Holding URL interceptors
  • Resolving a URL into a NavScreen
  • Passing the NavScreen to the currently attached Host

The real page stack is still managed by NavBackStackController.

This boundary is important for Compose: navigation state is not hidden inside a global singleton, but explicitly owned by the page Host. NavCenter is more like a cross-module dispatcher.

If you need login interception, gray release routing, or URL rewriting, you can handle it through interceptors:

NavCenter.addInterceptor { url ->
    // Return a new URL, or return null to block this navigation
    url
}
Enter fullscreen mode Exit fullscreen mode

When This Library Fits

I think it fits projects that:

  • Already use Compose Navigation 3
  • Have multiple business modules
  • Want to keep type safety for page navigation
  • Want cross-module navigation through stable route contracts
  • Need to reuse navigation declarations across Android, iOS, Desktop, Wasm, and other Compose Multiplatform targets

If the project is small and all pages live in one module, the native Navigation 3 APIs are already enough.

The main problems nav3-helper solves are: writing less repeated registration code, avoiding hand-written parameter parsing, and reducing direct dependencies between modules.

Summary

Navigation 3 brings navigation back to a state-driven model, which is a good direction for Compose.

On top of that, nav3-helper adds a layer of capabilities commonly needed in modular projects:

  • Declare pages with @Screen
  • Generate Destinations and Registries with KSP
  • Mount a navigation Host quickly with NavDisplayHelper
  • Support cross-module route navigation with NavCenter.navigate(url)
  • Restore primitive types, default values, nullable values, and @Serializable parameters from query strings
  • Support page result callbacks with setResult / consumeResultEffect

The purpose of this library is simple: keep page declarations simple, keep cross-module navigation clear, and do not break Navigation 3's state model.

This is the first version, so some features may still need improvement. If you are interested, contributions and ideas are welcome.

If you are working on a Compose Multiplatform project or a modular Compose project, this helper library may be worth trying.

Top comments (0)