./blog / type-safe-navigation-compose

Type-safe navigation in Compose — the right way to migrate

Apr 22, 2026 · 09:00 · 28 min ·#android #kotlin #compose

Navigation 2.8 shipped type-safe routes in September 2024 and I have been migrating codebases to it ever since. The API is a genuine improvement — stringly-typed routes were one of the last rough edges in Compose Navigation — but the migration is not mechanical. There are a handful of places where the new model forces you to reconsider how you structured things, and a few gotchas that are not obvious from the documentation.

This post walks through a full migration of a realistic app: authentication flow, nested graphs, deep links, and programmatic back-stack manipulation.

What changed

Before 2.8, routes were strings:

// Before — string routes
const val ROUTE_HOME = "home"
const val ROUTE_PROFILE = "profile/{userId}"

NavHost(navController, startDestination = ROUTE_HOME) {
    composable(ROUTE_HOME) { HomeScreen() }
    composable(
        route = ROUTE_PROFILE,
        arguments = listOf(navArgument("userId") { type = NavType.StringType }),
    ) { backStackEntry ->
        val userId = backStackEntry.arguments?.getString("userId") ?: return@composable
        ProfileScreen(userId)
    }
}

// Navigating
navController.navigate("profile/$userId")

The problems are obvious in retrospect: no compile-time safety, argument extraction is boilerplate-heavy, and typos in route strings fail silently at runtime.

With 2.8, routes are @Serializable data objects and classes:

// After — type-safe routes
@Serializable
object Home

@Serializable
data class Profile(val userId: String)

NavHost(navController, startDestination = Home) {
    composable<Home> { HomeScreen() }
    composable<Profile> { backStackEntry ->
        val args = backStackEntry.toRoute<Profile>()
        ProfileScreen(args.userId)
    }
}

// Navigating
navController.navigate(Profile(userId = user.id))

Arguments are constructor parameters. The serialization library encodes them into the route URL under the hood, and toRoute<T>() deserialises them back. The compiler catches mismatched types and missing arguments before the app runs.

Setup

Add the dependencies. You need kotlinx-serialization-json alongside the navigation artifact:

// build.gradle.kts (app module)
plugins {
    alias(libs.plugins.kotlin.serialization)
}

dependencies {
    implementation(libs.androidx.navigation.compose)    // 2.8.0+
    implementation(libs.kotlinx.serialization.json)
}
# libs.versions.toml
[versions]
navigation = "2.8.9"
kotlinx-serialization = "1.7.3"

[libraries]
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

Defining the route graph

Keep all route definitions in one file. This makes the graph legible and prevents routes from leaking across feature modules.

// navigation/Routes.kt
import kotlinx.serialization.Serializable

// Top-level destinations
@Serializable object Home
@Serializable object Settings

// Auth graph
@Serializable object AuthGraph
@Serializable object Login
@Serializable object Register
@Serializable data class VerifyEmail(val email: String)

// Profile graph
@Serializable object ProfileGraph
@Serializable data class Profile(val userId: String)
@Serializable data class EditProfile(val userId: String)
@Serializable data class Followers(
    val userId: String,
    val initialTab: FollowerTab = FollowerTab.FOLLOWERS,
)

enum class FollowerTab { FOLLOWERS, FOLLOWING }

One important constraint: every type used as a route argument must be serializable. Enums work out of the box. For custom types, you need a custom NavType — more on that below.

Nested navigation graphs

Nested graphs work with the same pattern. Declare the graph root as a @Serializable object and pass it to navigation<T>:

@Composable
fun AppNavHost(
    navController: NavHostController,
    isLoggedIn: Boolean,
    modifier: Modifier = Modifier,
) {
    val startDestination: Any = if (isLoggedIn) Home else AuthGraph

    NavHost(
        navController = navController,
        startDestination = startDestination,
        modifier = modifier,
    ) {
        // Auth nested graph
        navigation<AuthGraph>(startDestination = Login) {
            composable<Login> {
                LoginScreen(
                    onLoginSuccess = {
                        navController.navigate(Home) {
                            popUpTo<AuthGraph> { inclusive = true }
                        }
                    },
                    onNavigateToRegister = { navController.navigate(Register) },
                )
            }
            composable<Register> {
                RegisterScreen(
                    onRegistered = { email ->
                        navController.navigate(VerifyEmail(email)) {
                            popUpTo<Register> { inclusive = true }
                        }
                    }
                )
            }
            composable<VerifyEmail> { backStackEntry ->
                val args = backStackEntry.toRoute<VerifyEmail>()
                VerifyEmailScreen(
                    email = args.email,
                    onVerified = {
                        navController.navigate(Home) {
                            popUpTo<AuthGraph> { inclusive = true }
                        }
                    },
                )
            }
        }

        // Main destinations
        composable<Home> { HomeScreen(navController) }
        composable<Settings> { SettingsScreen() }

        // Profile nested graph
        navigation<ProfileGraph>(startDestination = Profile("")) {
            composable<Profile> { backStackEntry ->
                val args = backStackEntry.toRoute<Profile>()
                ProfileScreen(
                    userId = args.userId,
                    onEditProfile = { navController.navigate(EditProfile(args.userId)) },
                    onFollowers = { navController.navigate(Followers(args.userId)) },
                )
            }
            composable<EditProfile> { backStackEntry ->
                val args = backStackEntry.toRoute<EditProfile>()
                EditProfileScreen(userId = args.userId)
            }
            composable<Followers> { backStackEntry ->
                val args = backStackEntry.toRoute<Followers>()
                FollowersScreen(userId = args.userId, initialTab = args.initialTab)
            }
        }
    }
}

Note popUpTo<AuthGraph> { inclusive = true } — the generic version of popUpTo takes the route type directly. No more string literals in back-stack manipulation.

Custom argument types

For arguments that are not primitives or enums, you need a custom NavType. Here is a pattern for passing a Parcelable — or more practically, for passing a sealed class that represents some lightweight state:

@Serializable
sealed class TransactionFilter {
    @Serializable data object All : TransactionFilter()
    @Serializable data class ByCategory(val categoryId: String) : TransactionFilter()
    @Serializable data class DateRange(val from: Long, val to: Long) : TransactionFilter()
}

@Serializable
data class TransactionList(
    val filter: TransactionFilter = TransactionFilter.All,
)

Sealed classes with @Serializable on every subtype work without a custom NavType — the serialization library handles the polymorphism. The encoded route will contain the JSON representation as a query parameter.

Where you actually need a custom NavType is for third-party types you cannot annotate. The pattern looks like this:

inline fun <reified T : Any> serializableNavType(
    isNullableAllowed: Boolean = false,
): NavType<T> = object : NavType<T>(isNullableAllowed) {
    override fun get(bundle: Bundle, key: String): T? =
        bundle.getString(key)?.let { Json.decodeFromString(it) }

    override fun parseValue(value: String): T =
        Json.decodeFromString(Uri.decode(value))

    override fun serializeAsValue(value: T): String =
        Uri.encode(Json.encodeToString(value))

    override fun put(bundle: Bundle, key: String, value: T) =
        bundle.putString(key, Json.encodeToString(value))
}

Register it in the composable declaration:

val myType = serializableNavType<MyType>()

composable<MyDestination>(
    typeMap = mapOf(typeOf<MyType>() to myType),
) { ... }

Deep links

Deep link handling integrates cleanly with the new API. Declare the pattern on the composable and the library maps the URL parameters to your route arguments automatically:

composable<Profile>(
    deepLinks = listOf(
        navDeepLink<Profile>(basePath = "https://singhangad.in/profile")
    ),
) { backStackEntry ->
    val args = backStackEntry.toRoute<Profile>()
    ProfileScreen(userId = args.userId)
}

For the URL https://singhangad.in/profile?userId=abc123, the library deserialises userId directly into Profile(userId = "abc123"). No manual Uri parsing.

Declare the intent filter in AndroidManifest.xml as before:

<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="singhangad.in" />
</intent-filter>

Navigating from a ViewModel

The navigation controller should not live in the ViewModel — it is a UI concern. Use a one-shot event channel instead:

// Shared pattern for navigation events
sealed interface NavEvent {
    data class Navigate(val route: Any) : NavEvent
    data class NavigateAndClearStack(val route: Any, val clearTo: Any) : NavEvent
    data object NavigateUp : NavEvent
}

class LoginViewModel : ViewModel() {
    private val _navEvents = Channel<NavEvent>(Channel.BUFFERED)
    val navEvents = _navEvents.receiveAsFlow()

    fun onLoginSuccess() {
        viewModelScope.launch {
            _navEvents.send(
                NavEvent.NavigateAndClearStack(
                    route = Home,
                    clearTo = AuthGraph,
                )
            )
        }
    }
}

// In the composable
@Composable
fun LoginScreen(
    viewModel: LoginViewModel = hiltViewModel(),
    onNavEvent: (NavEvent) -> Unit,
) {
    LaunchedEffect(Unit) {
        viewModel.navEvents.collect { onNavEvent(it) }
    }
    // ...
}

Handle the events in the nav host:

composable<Login> {
    LoginScreen(
        onNavEvent = { event ->
            when (event) {
                is NavEvent.Navigate -> navController.navigate(event.route)
                is NavEvent.NavigateAndClearStack -> navController.navigate(event.route) {
                    popUpTo(event.clearTo) { inclusive = true }
                }
                NavEvent.NavigateUp -> navController.navigateUp()
            }
        }
    )
}

What to watch out for

Argument size limits. Route arguments are encoded into the URL and passed through the back stack, which goes through Bundle. Android's Bundle limit is 1 MB across the whole transaction, but in practice you should treat navigation arguments as IDs, not payloads. Pass a userId, not a serialised User object. Load the full object in the destination's ViewModel.

Default arguments. Constructor defaults work:

@Serializable
data class Followers(
    val userId: String,
    val initialTab: FollowerTab = FollowerTab.FOLLOWERS,
)

When navigating without specifying initialTab, the default is used. This replaces the old defaultValue on navArgument.

Nullable arguments. Mark nullable parameters with ? in the class definition. The serialized route will omit the query parameter when the value is null, and toRoute() will deserialise it back to null correctly.

@Serializable
data class Search(val query: String? = null)

Testing. The NavController fake from navigation-testing (TestNavHostController) works with type-safe routes the same way it worked with strings. Verify navigation calls with assertEquals(Profile(userId = "123"), navController.currentBackStackEntry?.toRoute<Profile>()).

The migration order that works

If you have an existing app with string routes, do this incrementally:

  1. Add the serialization plugin and kotlinx-serialization-json dependency
  2. Pick one isolated screen (ideally a leaf — no back-stack manipulation) and convert its route to a @Serializable object
  3. Convert its composable() call to composable<T>() and replace arguments?.getString(...) with toRoute<T>()
  4. Convert the navigate("route/$arg") call at the call site to navigate(RouteClass(arg))
  5. Repeat screen by screen, converting popUpTo strings to popUpTo<T>() as you go

Do not try to convert the entire graph at once. The old and new APIs are compatible in the same NavHost, so you can ship incrementally.