Mastering Jetpack Compose Navigation in 2025 — With Nested Graphs & Safe Args
👋 Introduction
Jetpack Compose has redefined how Android developers build UIs. It’s reactive, powerful, and offers complete control through code. But when it comes to navigation — especially in large-scale apps — things can quickly get out of hand if you don’t have a proper structure.
In this guide, we’ll walk you through a modern and professional approach to navigation in Jetpack Compose using:
- Navigation basics
- Nested graphs for complex flows
- Type-safe argument passing
- Best practices and real-world tips
Whether you’re building an onboarding flow, a tabbed app, or a multi-module product, this guide will give you the clarity and tools to scale with confidence.
🧭 1. Why Navigation Is Different in Compose
Classic Android navigation relied heavily on Fragments, XML navigation graphs, and tools like SafeArgs. But Compose brings a fully Kotlin-based, declarative approach.
1. Why Navigation Is Different in Compose
Navigation in Compose is fundamentally different from the old XML-based approach. There are no Fragments, no XML navigation graphs, and no SafeArgs plugin. Everything is done in Kotlin, which gives you more control — but also more responsibility.
Instead of defining navigation routes in XML, you define them in code using composable functions and a NavController. Your screens are not fragments anymore; they are Composables. This change makes the flow more declarative and reactive, but also means you need to structure your navigation logic yourself.
To get started, add the official dependency:
implementation("androidx.navigation:navigation-compose:2.7.5")
Then, define a basic navigation host:
@Composable
fun MainNavGraph(navController: NavHostController = rememberNavController()) {
NavHost(navController = navController, startDestination = "home") {
composable("home") { HomeScreen(navController) }
composable("details") { DetailsScreen() }
}
}
Use NavController to navigate between destinations:
navController.navigate("details")
3. Creating Clean Route Structures
Instead of using hardcoded route strings throughout your app, define your routes in a sealed class. This makes your code more maintainable and prevents typos.
sealed class Screen(val route: String) {
object Home : Screen("home")
object Details : Screen("details")
}
Now you can use the routes like this:
NavHost(navController, startDestination = Screen.Home.route) {
composable(Screen.Home.route) { HomeScreen(navController) }
composable(Screen.Details.route) { DetailsScreen() }
}
This approach keeps your navigation structure centralized and easier to refactor.
4. Using Nested Navigation Graphs
As your app grows, using nested graphs helps organize features like authentication, onboarding, or tabs into their own navigation flow.
Let’s say you have an onboarding flow and a main app flow. You can define them like this:
NavHost(navController, startDestination = "onboarding") {
navigation(startDestination = "welcome", route = "onboarding") {
composable("welcome") { WelcomeScreen(navController) }
composable("signup") { SignUpScreen(navController) }
}
navigation(startDestination = "home", route = "main") {
composable("home") { HomeScreen(navController) }
composable("profile") { ProfileScreen(navController) }
}
}
To switch flows (for example, from onboarding to the main app), you can use:
navController.navigate("main") {
popUpTo("onboarding") { inclusive = true }
}
This prevents stacking unnecessary screens and gives each flow its own structure.
5. Passing Arguments Safely Between Screens
Jetpack Compose doesn’t have an official SafeArgs solution yet, but there are clean ways to pass arguments between screens.
Option 1: Pass via route parameters
composable("details/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
DetailsScreen(userId = userId)
}
Navigate like this:
navController.navigate("details/42")
This is good for simple string or numeric values.
Option 2: Pass via SavedStateHandle
For more robust and type-safe access, especially when using ViewModels, use SavedStateHandle:
@HiltViewModel
class DetailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle
) : ViewModel() {
val userId = savedStateHandle.get<String>("userId")
}
This ensures your data is available even after process death or screen recreation.
6. Best Practices and Real-World Tips
Here are some hard-earned lessons to keep your navigation architecture clean and scalable:
- Define your routes using sealed classes.
- Group screens by feature, not by layer.
- Use nested graphs for modular flows like onboarding, auth, or settings.
- Handle the back stack explicitly with popUpTo() when changing flows.
- Use SavedStateHandle for argument recovery in ViewModels.
- Avoid passing large objects via route strings.
- Don’t over-engineer early — but structure your routes for easy expansion.
7. Testing Navigation in Compose
Navigation is one of the easiest areas to break during refactoring. Thankfully, Compose makes it testable with TestNavHostController.
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
composeTestRule.setContent {
MainNavGraph(navController = navController)
}
You can simulate user interactions and verify the current destination like this:
composeTestRule.onNodeWithText("Go to Details").performClick()
assert(navController.currentDestination?.route == "details")
Testing your navigation logic gives you confidence when restructuring flows.
✅ Conclusion
Navigation in Jetpack Compose puts you in full control. But that also means you need a clear structure, smart patterns, and discipline.
By following the practices we covered — sealed classes for routes, nested graphs, and safe argument passing — you can build apps that scale beautifully.
Don’t let navigation become a mess. Architect it like a pro.