DEV Community

SuriDevs
SuriDevs

Posted on • Originally published at suridevs.com

Jetpack Navigation 3 Migration: Type-Safe Routes & Nav2

I have a production app with four bottom tabs, nine navigation graphs, and 40+ screens. Deep links from push notifications. OAuth callbacks from third-party services. Shared ViewModels across multi-screen flows.

Navigation 2 handled all of this. Messily, with string routes and withArgs hacks, but it worked.

Then I tried migrating to Navigation 3.

The first screen took 20 minutes. The bottom navigation took three days. Deep links? I almost gave up.

Nav3 is genuinely better -- type-safe routes, a visible back stack you actually control, no more NavController black box. But the migration path has gaps that the docs don't warn you about. Shared ViewModels work differently. Deep links are now your problem. Returning results between screens has no built-in solution.

This is everything I learned migrating a production app. The good parts, the painful parts, and the workarounds I wish someone had written down before I started.

What Nav3 Actually Changes

If you've been using Navigation 2, here's the mental shift:

Back stack ownership:
Nav2 — NavController manages everything behind a black box.
Nav3 — You own a SnapshotStateList back stack. Fully visible, fully yours.

Route definitions:
Nav2 — String routes: "project_detail/{projectId}/{projectName}"
Nav3 — Data classes: ProjectDetail(projectId = 42L, projectName = "Alpha")

Screen registration:
Nav2 — NavHost + composable() DSL
Nav3 — NavDisplay + entryProvider

Argument types:
Nav2 — navArgument(type = NavType.LongType)
Nav3 — Just... a Long property on a data class

Reading arguments:
Nav2 — backStackEntry.arguments?.getLong("projectId")
Nav3 — key.projectId

Navigation calls:
Nav2 — navController.navigate(route) / navController.popBackStack()
Nav3 — backStack.add(route) / backStack.removeLastOrNull()

The argument passing alone made the migration worth it. Take a screen that needs a project ID and name. The Nav2 route looked like this:

// Nav2: string route with arguments
"project_detail/{projectId}/{projectName}"
Enter fullscreen mode Exit fullscreen mode

Two navArgument declarations. Two backStackEntry.arguments?.getXxx() calls. One wrong type and you get a runtime crash with an unhelpful error message.

Nav3 equivalent:

// Nav3: just a data class
@Serializable
data class ProjectDetail(
    val projectId: Long,
    val projectName: String
) : NavKey
Enter fullscreen mode Exit fullscreen mode

Compile-time safe. IDE autocomplete. No parsing.

Setting Up Nav3

Dependencies

# gradle/libs.versions.toml

[versions]
nav3 = "1.0.1"
lifecycleNav3 = "2.10.0-rc01"

[libraries]
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3" }
androidx-lifecycle-viewmodel-nav3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleNav3" }
Enter fullscreen mode Exit fullscreen mode
// app/build.gradle.kts
dependencies {
    implementation(libs.androidx.navigation3.runtime)
    implementation(libs.androidx.navigation3.ui)
    implementation(libs.androidx.lifecycle.viewmodel.nav3)
}
Enter fullscreen mode Exit fullscreen mode

You also need compileSdk = 36 and minSdk = 23. The minSdk bump from 21 caught me off guard -- I had to drop Android 4.x support. Check your analytics first.

Define Your Routes

Every route is now a @Serializable class implementing NavKey. No more string constants.

// navigation/Routes.kt

import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable

// ---- Bottom tabs ----
@Serializable data object HomeTab : NavKey
@Serializable data object ProjectsTab : NavKey
@Serializable data object MessagesTab : NavKey
@Serializable data object SettingsTab : NavKey

// ---- Auth ----
@Serializable data object Welcome : NavKey
@Serializable data object Login : NavKey
@Serializable data class VerifyOtp(val email: String, val token: String) : NavKey

// ---- Projects ----
@Serializable data class ProjectDetail(
    val projectId: Long,
    val projectName: String
) : NavKey
@Serializable data class TaskDetail(
    val taskId: Long,
    val taskTitle: String,
    val projectId: Long
) : NavKey
Enter fullscreen mode Exit fullscreen mode

Bottom Navigation with Multiple Back Stacks

This is where Nav3 gets interesting -- and where I spent three days.

In Nav2, each bottom tab's back stack was managed invisibly by NavController with saveState/restoreState. In Nav3, you manage each tab's back stack explicitly.

The Navigation State

class AppNavigationState(
    val startTab: NavKey,
    private val _currentTab: MutableState<NavKey>,
    val tabStacks: Map<NavKey, NavBackStack<NavKey>>
) {
    var currentTab: NavKey
        get() = _currentTab.value
        set(value) { _currentTab.value = value }

    val activeStacks: List<NavKey>
        get() = if (currentTab == startTab) {
            listOf(startTab)
        } else {
            listOf(startTab, currentTab)
        }
}

@Composable
fun rememberAppNavigationState(
    startTab: NavKey = HomeTab,
    tabs: Set<NavKey> = setOf(HomeTab, ProjectsTab, MessagesTab, SettingsTab)
): AppNavigationState {
    val currentTab = rememberSaveable { mutableStateOf(startTab) }
    val tabStacks = tabs.associateWith { tab -> rememberNavBackStack(tab) }

    return remember(startTab, tabs) {
        AppNavigationState(
            startTab = startTab,
            _currentTab = currentTab,
            tabStacks = tabStacks
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

The Navigator

class AppNavigator(private val state: AppNavigationState) {

    fun navigateTo(route: NavKey) {
        if (route in state.tabStacks.keys) {
            state.currentTab = route
        } else {
            state.tabStacks[state.currentTab]?.add(route)
        }
    }

    fun goBack(): Boolean {
        val currentStack = state.tabStacks[state.currentTab] ?: return false
        val currentRoute = currentStack.lastOrNull() ?: return false

        // At root of non-start tab? Go back to start tab
        if (currentRoute == state.currentTab && state.currentTab != state.startTab) {
            state.currentTab = state.startTab
            return true
        }

        // At root of start tab? Let the system handle it (exit app)
        if (currentRoute == state.startTab) {
            return false
        }

        currentStack.removeLastOrNull()
        return true
    }

    fun switchTab(tab: NavKey) {
        state.currentTab = tab
    }
}
Enter fullscreen mode Exit fullscreen mode

The Hard Parts (And Workarounds)

1. Deep Links Are Now Your Problem

Nav2 had navDeepLink { uriPattern = "myapp://projects/{projectId}" }. Nav3 has nothing built in. You parse intents yourself and push routes onto the back stack.

object DeepLinkHandler {

    fun handleIntent(intent: Intent, navigator: AppNavigator): Boolean {
        val uri = intent.data ?: return false

        return when {
            uri.pathSegments.firstOrNull() == "projects" -> {
                val projectId = uri.pathSegments.getOrNull(1)?.toLongOrNull() ?: return false
                val projectName = uri.getQueryParameter("name") ?: "Unknown"

                navigator.switchTab(ProjectsTab)
                navigator.navigateTo(ProjectDetail(projectId = projectId, projectName = projectName))
                true
            }
            else -> false
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Returning Results Between Screens

Nav2 had previousBackStackEntry?.savedStateHandle. Nav3 doesn't. Here's the pattern I use:

// 1. Define a result holder
class SelectMemberResult {
    var selectedMemberId: Long? by mutableStateOf(null)
}

// 2. Provide it via CompositionLocal
val LocalSelectMemberResult = staticCompositionLocalOf { SelectMemberResult() }

// 3. In the picker screen, set the result and go back
entry<SelectMemberForTeam> {
    val result = LocalSelectMemberResult.current
    SelectMemberScreen(
        onMemberSelected = { memberId ->
            result.selectedMemberId = memberId
            navigator.goBack()
        }
    )
}
Enter fullscreen mode Exit fullscreen mode

3. Hilt ViewModels

Nav3 + Hilt works, but route arguments no longer appear in SavedStateHandle. You pass them manually:

// DON'T rely on SavedStateHandle for route args in Nav3:
@HiltViewModel
class ProjectDetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle  // Empty! No route args here
) : ViewModel()

// DO pass the key directly:
entry<ProjectDetail> { key ->
    val viewModel: ProjectDetailViewModel = hiltViewModel()
    LaunchedEffect(key) {
        viewModel.initialize(key.projectId, key.projectName)
    }
    ProjectDetailScreen(viewModel = viewModel, onBack = { navigator.goBack() })
}
Enter fullscreen mode Exit fullscreen mode

4. Screen Transition Animations

Nav3 has built-in support for transitions, including predictive back gesture animations.

NavDisplay(
    entries = entries,
    onBack = { navigator.goBack() },
    transitionSpec = {
        slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start) togetherWith
            slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start)
    },
    popTransitionSpec = {
        slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End) togetherWith
            slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End)
    }
)
Enter fullscreen mode Exit fullscreen mode

Predictive back -- where the previous screen peeks as you swipe -- works out of the box.

The Migration Checklist

If you're migrating from Nav2, here's the order that worked for me:

  1. Add Nav3 dependencies alongside Nav2 -- they don't conflict
  2. Convert route definitions to @Serializable + NavKey data classes
  3. Build NavigationState and Navigator for your bottom tabs
  4. Migrate one tab at a time -- start with the simplest one
  5. Move deep link handling to your own DeepLinkHandler
  6. Convert result-passing screens to CompositionLocal pattern
  7. Remove Nav2 dependencies after all screens are migrated
  8. Test predictive back on every screen

Quick Reference

Nav2                              Nav3
────────────────────────────────────────────────────────
NavHost { }                   →   NavDisplay(entries, onBack)
composable<Route> { }         →   entry<Route> { key -> }
navController.navigate(r)     →   backStack.add(r)
navController.popBackStack()  →   backStack.removeLastOrNull()
NavType.LongType              →   just a Long property
navDeepLink { }               →   manual intent parsing
savedStateHandle result       →   CompositionLocal or shared flow
navigation<Parent> { }       →   gone (use entry decorators)
Enter fullscreen mode Exit fullscreen mode

When NOT to Migrate

Don't migrate if:

  • Your app uses Fragments or Views -- Nav3 is Compose-only
  • You depend heavily on nested graph scoping -- still rough in Nav3
  • You need deep links handled by the framework -- you'll write that yourself
  • Your team is mid-sprint -- budget a week for 40 screens

Do migrate if:

  • You're starting a new Compose project
  • You're tired of runtime crashes from wrong argument types
  • You want predictive back gestures without extra work
  • You want to actually see your back stack

Wrapping Up

Nav3 is what Navigation for Compose should have been from the start. The back stack is yours. The routes are type-safe. Arguments are just properties.

For new projects, use Nav3. For existing apps, migrate when you have the bandwidth -- it's worth it, but budget a week, not an afternoon.

For the Nav2 approach with type-safe routes, popUpTo, and nested graphs, check out the Jetpack Compose Navigation tutorial at https://www.suridevs.com/blog/posts/navigation-component-jetpack-compose-complete-guide/. For structuring your ViewModels behind these screens, see the MVVM Architecture guide at https://www.suridevs.com/blog/posts/mvvm-jetpack-compose-authentication-guide/.


Originally published at SuriDevs

Top comments (0)