KMP · Compose Multiplatform · Navigation3

Navigation that
writes itself.

NavEase is a Kotlin Multiplatform navigation library with three clean integration paths: Plugin + @AutoRegister for zero-config screen discovery, a zero-rebuild DSL Graph for instant IDE resolution, and Explicit ActivityScreen<K> for typed OOP registration. No reflection. No string routes. No boilerplate.

Path 1 — Plugin + @AutoRegister
// 1. Annotate your screens — type-safe navKey, no casting
@AutoRegister
class HomeScreen : ActivityScreen<AppScreens.Home>() {
    @Composable
    override fun Content(navKey: AppScreens.Home, nav: NavController) {
        Button(onClick = { nav.navigate(AppScreens.Detail(id = "42")) }) {
            Text("Open Detail")
        }
    }
}

// 2. Use the host with your Root type — everything else is automatic
@Composable
fun App() {
    NavEaseHost<AppScreens>(   // ← Root type required ✅
        start = AppScreens.Home,
        onExitRequest = { /* ... */ }
    )
}

// iOS entry — completely clean, zero extra wiring needed
fun MainViewController() = ComposeUIViewController { App() }

Runs natively on every platform

🤖 Android
🍎 iOS
🖥️ Desktop (JVM)
🌐 Web (JS)
🌐 Web (WASM)

Everything you need.
Nothing you don't.

NavEase gives you compile-time safety, zero runtime overhead, and a dead-simple API that doesn't get in your way.

Three Clean Paths

@AutoRegister — one annotation, NavEaseHost<Root>(start), auto-wires on all platforms. DSL Graph — plain Kotlin, no annotations, instant IDE resolution. Explicit ActivityScreen<K> — typed OOP registration via NavEaseHost<Root> { add(Screen()) }. All three share the same runtime engine.

🔌

Plugin-Only Setup

One line: id("io.github.alims-repo.navease"). The Gradle plugin auto-applies KSP, adds navease-runtime, registers the source directory, and wires task ordering. Zero manual boilerplate.

🪄

@AutoRegister

Annotate any ActivityScreen<K> subclass with @AutoRegister. KSP discovers your screens and wires them automatically when you use NavEaseHost<Root>(start). No manual screen listing, no navEaseBootstrap() call required.

🔄

Zero-Rebuild DSL

navEaseGraph { screen<K> { … } } — define screens in plain Kotlin. No annotation processing, no rebuild required after adding a screen. The IDE resolves everything immediately.

🏗️

Typed ActivityScreen<K>

Extend ActivityScreen<K> and receive a fully-typed navKey: K in Content(). No casting, no helper functions — direct field access on your sealed key class.

Shared Element Transitions

Enable with one parameter: NavEaseHost(enableSharedTransitions = true). Use LocalNavEaseSharedTransitionScope in any screen, zero extra setup.

🔒

State Restoration

SavedStateConfiguration is built and wired automatically from registered screens. Your back stack survives process death out of the box on every platform.

🗺️

Powered by Navigation3

Built on JetBrains Navigation3 — the new Compose-first, serializable NavKey API. Works on every KMP target: Android, iOS, Desktop, Web.

🔁

Smart Navigation

singleTop, popUpTo, popToIndex, replace (finish = true) — all the navigation patterns you need, typed and safe.

💉

Composition Local

LocalNavEaseController.current provides the nav controller to any deeply nested composable — no prop drilling.

Three ways to integrate.
Pick the one that fits your workflow.

All three paths share the same runtime engine, the same NavEaseHost, and the same typed navigation API. The difference is how much you want KSP to automate versus how much plain-Kotlin control you want.

DSL Graph (Zero Rebuild)

No KSP. No annotations. No rebuild. Define screens in a Kotlin DSL and navigate with full IDE support immediately.

  • Instant IDE resolution — no rebuild ever
  • No plugin or annotation processor needed
  • Typed screen lambdas — key is fully typed
  • Perfect for prototyping and small projects
Start This Path →
🏗️

Explicit ActivityScreen<K>

Class-based OOP registration. Extend ActivityScreen<K> and register each screen explicitly inside NavEaseHost<Root>.

  • No KSP, no annotations, no rebuild
  • Typed navKey: K in Content() — no casting
  • Familiar OOP structure — great for class-based codebases
  • Explicit screen list = zero magic, full control
Start This Path →

Path 1: Plugin + @AutoRegister

The fastest way to production. One plugin line, one annotation per screen, and a type-safe NavEaseHost<Root>(start). The registry auto-wires on every platform — no navEaseBootstrap() call needed. iOS entry point stays completely clean.

1

Apply the Plugin

In your shared/build.gradle.kts:

shared/build.gradle.kts
plugins {
    kotlin("multiplatform")
    id("io.github.alims-repo.navease") version "0.1.3"
}

The plugin automatically applies KSP, adds navease-runtime, registers the generated source directory, and wires all compilation tasks to depend on KSP.

2

Define Your NavKeys

Create a sealed class in commonMain that extends NavEaseRoot (no @Serializable needed):

AppScreens.kt
sealed class AppScreens : NavEaseRoot {
    data object Home : AppScreens()

    data class Detail(val id: String) : AppScreens()
}
3

Annotate Your Screens

Extend ActivityScreen<K> and add @AutoRegister.

HomeScreen.kt + DetailScreen.kt
@AutoRegister
class HomeScreen : ActivityScreen<AppScreens.Home>() {
    @Composable
    override fun Content(navKey: AppScreens.Home, navController: NavController) {
        Button(onClick = { navController.navigate(AppScreens.Detail(id = "42")) }) {
            Text("Open Detail")
        }
    }
}

@AutoRegister
class DetailScreen : ActivityScreen<AppScreens.Detail>() {
    data class Result(val confirmed: Boolean)

    @Composable
    override fun Content(navKey: AppScreens.Detail, navController: NavController) {
        Text("ID: ${navKey.id}")   // ← typed, no casting ✅
        Button(onClick = {
            navController.backWithResult(Result(confirmed = true))
        }) { Text("Done") }
    }
}

KSP validates at compile time: no duplicate NavKeys, and all screens must share the same sealed root. You specify the start destination when calling NavEaseHost.

4

Host Your App

The registry auto-wires on every platform — no navEaseBootstrap() call needed anywhere. Just drop NavEaseHost into App.kt:

App.kt — commonMain
@Composable
fun App() {
    MaterialTheme {
        NavEaseHost<AppScreens>(   // ← type param + start destination ✅
            start = AppScreens.Home,
            onExitRequest = { /* show exit dialog or finish() */ },
            enableSharedTransitions = true,
            navTransition = NavTransition.Push,
        )
    }
}

Registry auto-initialization: The generated NavEaseHost overloads handle everything for you on every platform (Android/JVM, iOS/Native, JS/Wasm). Your iOS MainViewController stays completely clean — just ComposeUIViewController { App() }.

5

Observe Results

In the parent screen, collect the typed result via the runtime API:

HomeScreen.kt (result observer)
val result by navController.resultOf<DetailScreen.Result>()
result?.let { Text("Confirmed: ${it.confirmed}") }

Path 2: DSL Graph (Zero Rebuild)

No KSP. No annotations. Define everything in Kotlin and navigate immediately with full IDE support.

1

Add the Runtime

You only need the runtime dependency — no KSP plugin required:

shared/build.gradle.kts
plugins {
    kotlin("multiplatform")
    // The NavEase plugin is optional for this path.
    // You can also add the runtime manually:
}

kotlin {
    sourceSets {
        commonMain {
            dependencies {
                implementation("io.github.alims-repo:navease-runtime:0.1.3")
            }
        }
    }
}
2

Define NavKeys

Create a plain @Serializable sealed class — no annotations on the class itself:

AppScreen.kt
@Serializable
sealed class AppScreen : NavKey {
    @Serializable data object Home   : AppScreen()
    @Serializable data class  Detail(val id: String) : AppScreen()
}
3

Build the Graph

Use navEaseGraph { } — each screen lambda receives a typed key:

Graph.kt
val appGraph = navEaseGraph(start = AppScreen.Home) {
    screen<AppScreen.Home>   { HomeScreen() }
    screen<AppScreen.Detail> { key -> DetailScreen(id = key.id) }
    //                         ^^^  typed key — no casting ✅
}

The screen<K> { } lambda receives the fully-typed key. IDE resolves key.id instantly — no rebuild ever needed after adding a screen.

4

Host the Graph

Pass the graph to NavEaseHost — single call, zero boilerplate:

App.kt
@Composable
fun App() {
    MaterialTheme {
        NavEaseHost(   // ← DSL overload ✅
            graph = appGraph,
            onExitRequest = {},
            navTransition = NavTransition.Push,
        )
    }
}
5

Navigate & Return Results

Use the runtime API directly — no generated code needed:

AnyScreen.kt
// Navigate
val nav = LocalNavEaseController.current
nav?.navigate(AppScreen.Detail(id = "abc"))

// Return result
data class DetailResult(val ok: Boolean)
nav.backWithResult(DetailResult(ok = true))

// Observe result in parent screen
val result by nav.resultOf<DetailResult>()

Path 3: Explicit ActivityScreen<K>

No KSP. No annotations. No rebuild. Extend ActivityScreen<K> and register each screen explicitly inside a typed NavEaseHost<Root> block. Your IDE resolves everything immediately — plain Kotlin, full OOP control.

1

Add the Runtime

Only navease-runtime is needed — no KSP plugin, no annotation processor:

shared/build.gradle.kts
kotlin {
    sourceSets {
        commonMain {
            dependencies {
                implementation("io.github.alims-repo:navease-runtime:0.1.3")
            }
        }
    }
}
2

Define NavKeys

Same plain @Serializable sealed class used by all paths:

AppScreens.kt
@Serializable
sealed class AppScreens : NavKey {
    @Serializable data object Home   : AppScreens()
    @Serializable data class  Detail(val id: String) : AppScreens()
}
3

Extend ActivityScreen<K>

Each screen class receives a fully-typed navKey: K in Content() — no casting, no generated extensions:

HomeScreen.kt + DetailScreen.kt
class HomeScreen : ActivityScreen<AppScreens.Home>() {
    @Composable
    override fun Content(navKey: AppScreens.Home, navController: NavController) {
        Button(onClick = { navController.navigate(AppScreens.Detail(id = "42")) }) {
            Text("Open Detail")
        }
    }
}

class DetailScreen : ActivityScreen<AppScreens.Detail>() {
    data class Result(val confirmed: Boolean)

    @Composable
    override fun Content(navKey: AppScreens.Detail, navController: NavController) {
        Text("ID: ${navKey.id}")   // ← typed, no casting ✅
        Button(onClick = {
            navController.backWithResult(Result(confirmed = true))
        }) { Text("Done") }
    }
}
4

Register & Host

Use the typed NavEaseHost<Root> overload and call add(Screen()) for each screen. SavedStateConfiguration is built automatically from the registered screens:

App.kt
@Composable
fun App() {
    MaterialTheme {
        NavEaseHost<AppScreens>(   // ← typed root, explicit registration ✅
            start = AppScreens.Home,
            onExitRequest = {},
            enableSharedTransitions = true,
            navTransition = NavTransition.Push,
        ) {
            add(HomeScreen())
            add(DetailScreen())
        }
    }
}

Adding a new screen is as simple as creating the class and calling add() here — no rebuild, no annotation, no plugin required.

5

Observe Results

Nest result classes inside the child screen for clear ownership, then observe them in the parent:

HomeScreen.kt (result observer)
val result by navController.resultOf<DetailScreen.Result>()
result?.let { Text("Confirmed: ${it.confirmed}") }

Plugin Extension Reference

Every option available in the navease { } block.

Property Type Default Description
version String "" (plugin bundled) Pin a specific NavEase version for both runtime and KSP processor.
addRuntimeDependency Boolean true Automatically adds navease-runtime to commonMain. Set false to manage it yourself.
kspProcessorDependency Any? null (Maven Central) Override with project(":navease-ksp") for local monorepo development.
runtimeDependency Any? null (Maven Central) Override with project(":navease-runtime") for local monorepo development.
generatedPackage String "" Custom package for KSP-generated files. Forwards to navease.generatedPackage KSP arg.
Example: Local Monorepo Setup
navease {
    version = "0.1.3"
    kspProcessorDependency = project(":navease-ksp")
    runtimeDependency      = project(":navease-runtime")
    generatedPackage       = "com.example.myapp.navigation"
}

Troubleshooting & Common Issues

This means KSP hasn't generated the initializer yet. Run ./gradlew :shared:kspCommonMainKotlinMetadata once to generate AutoRegisterScreens.kt, then sync the IDE. The registry bootstraps automatically when you use NavEaseHost<Root> — no navEaseBootstrap() call is required in your App.kt or platform entry points.

Each NavKey type may only be handled by one screen — using the same AppScreens.Detail in two different ActivityScreen classes will produce an error. Fix: ensure only one screen maps to each NavKey. Also ensure all screens share the same sealed root class.

Yes, but they must use separate NavEaseHost instances (e.g., one for a nested tab subgraph using the DSL, and one for the root using @AutoRegister). Do not mix the two approaches inside the same back stack.

On iOS/Native the registry is populated automatically by the generated NavEaseHost overloads, which call navEaseBootstrap() for you on first composition. No manual call is needed in your entry points.

If you still see this error, the most likely cause is that KSP has not run yet. Run:

./gradlew :shared:kspCommonMainKotlinMetadata

Then rebuild the iOS framework. Your MainViewController.kt stays completely clean — just ComposeUIViewController { App() }. No imports, no extra calls.

1. Keep your @Serializable sealed NavKey class. 2. Remove @AutoRegister annotations and extend plain classes. 3. Build a navEaseGraph { screen<K> { } } block. 4. Replace the NavEaseHost<Root>(start) call with NavEaseHost(graph = appGraph). 5. Remove the NavEase plugin if you no longer need KSP — only navease-runtime is required for the DSL path.

Yes. NavEase targets the stable Navigation3 serializable NavKey API. Bump the versions in your own libs.versions.toml; the NavEase runtime depends on Navigation3 but does not pin a specific version aggressively.

Transparent by design.

NavEase generates readable, auditable Kotlin. No magic — just code that you could have written yourself, but now you don't have to.

AutoRegisterScreens.kt — generated by KSP
// Generated from @AutoRegister annotations
package io.github.alimsrepo.navease.generated

private object NavEaseAutoInit {
    init {
        NavEaseAutoRegistry.addEntry(
            SplashScreen(), AppScreens.Splash::class,
            AppScreens::class, NavEase_Splash_Ser  // start ✅
        )
        NavEaseAutoRegistry.addEntry(
            HomeScreen(), AppScreens.Home::class,
            AppScreens::class, NavEase_Home_Ser
        )
        // … all other @AutoRegister screens …
    }
}

private val _navEaseAutoInit: Any = NavEaseAutoInit

// Called automatically by the generated NavEaseHost overloads
fun navEaseBootstrap() {
    _navEaseAutoInit   // triggers NavEaseAutoInit on Native/JS
}
The complete integration — App.kt + iOS entry point
// commonMain — App.kt
// The registry bootstraps automatically on every platform. Just use NavEaseHost.
@Composable
fun App() {
    NavEaseHost<AppScreens>(start = AppScreens.Splash)   // ← that's it ✅
}

// iosMain — MainViewController.kt
// Completely clean — NavEaseHost handles the registry automatically.
fun MainViewController() = ComposeUIViewController { App() }

// Navigation — same on every platform
val nav = LocalNavEaseController.current
nav?.navigate(AppScreens.Detail(id = "abc"))

// Typed result — no casting
val result by nav.resultOf<DetailScreen.Result>()

Clean separation.
Three artifacts.

📦

navease-runtime

KMP commonMain library

  • NavEaseHost<Root>(start) — auto-discovers @AutoRegister screens; registry auto-wires on all platforms
  • NavEaseHost(graph) — DSL graph variant, no KSP needed
  • NavEaseHost<Root>(start) { add(Screen()) } — explicit typed ActivityScreen registration
  • ActivityScreen<K> — typed abstract base; override Content(navKey: K, …)
  • navEaseGraph { screen<K> { } } — zero-rebuild DSL builder
  • NavEaseAutoRegistry — global screen registry populated by KSP; auto-initialized on all platforms
  • NavEaseRoot — NavKey alias that removes @Serializable requirements for @AutoRegister keys
  • NavController — navigate / back / popUpTo / singleTop / backWithResult
  • LocalNavEaseController + LocalNavEaseSharedTransitionScope
⚙️

navease-ksp

JVM KSP annotation processor

  • NavEaseProcessor — reads @AutoRegister annotations
  • Generates NavEaseAutoInit object with direct addEntry() calls
  • iOS/Native/Web: generates NavEaseHost overloads that call navEaseBootstrap() automatically
  • Android/JVM: registry triggered via Class.forName class-loading inside NavEaseHost
  • Validates: one NavKey → one screen · all screens same sealed root
  • All generated code lives in io.github.alimsrepo.navease.generated
🔌

navease-gradle-plugin

Gradle plugin · ID io.github.alims-repo.navease

  • Auto-applies the KSP Gradle plugin — no id("com.google.devtools.ksp") needed
  • Adds navease-ksp to kspCommonMainMetadata automatically
  • Registers build/generated/ksp/metadata/commonMain/kotlin as a source dir
  • Wires all KMP compilations to depend on kspCommonMainKotlinMetadata
  • Optionally adds navease-runtime to commonMain
  • Forwards generatedPackage KSP arg · configurable via navease { } extension

Ready to simplify your KMP navigation?

NavEase is open-source and free. Star the repo and ship faster.