Navigation is deceptively complex. At first glance, it seems simple: user taps a button, a new screen appears. But in reality, navigation is the thread that weaves together your entire application's state, lifecycle, animations, and user expectations. Get it wrong, and your app feels janky. Get it right, and users don't even think about it—which is exactly what you want.
For years, developers building cross-platform apps faced an impossible choice: build navigation separately for each platform (Android, iOS, Web, Desktop), duplicating logic and creating maintenance nightmares, or force a lowest-common-denominator approach that feels alien on each platform. Navigation 3 changes this completely.
This article is a deep dive into Compose Multiplatform Navigation 3, exploring the philosophy, architecture, and practical patterns that the nav3-recipes project demonstrates. Whether you're building a small side project or a large-scale production app, these patterns will serve as your north star.
Before we talk about the solution, let's understand the problem.
Imagine you're building a social media app that needs to work on Android, iOS, Web, and Desktop. Your feature set includes:
- A feed where users can scroll through posts
- Tapping a post opens its details
- From details, they can navigate to the poster's profile
- From profile, they can view follow lists
- Each screen needs its own state management
- Back button should work intuitively on each platform
- Deep links from push notifications should work
In the old world, you'd write navigation code separately for Android, iOS, Web, and Desktop. Every change to the navigation flow means updating four separate implementations. Every bug fix needs to be applied four times. Every new feature requires thinking about four different navigation models.
The cognitive load is staggering. The maintenance burden is crushing.
Compose Multiplatform offers something radical: write your UI once, run it everywhere. But true multiplatform development requires more than just UI—it requires shared navigation, shared state management, and shared architecture.
Navigation 3 is the missing piece that makes this vision real.
Before diving into code, it's worth understanding the philosophy. Nav3 isn't just "Android Navigation but multiplatform." It's a rethinking of navigation from first principles.
The biggest innovation in Navigation 3 is type-safe routing.
https://gist.github.com/AndroidPoet/94731ceefcb3e155068e2b9e8e199e4d
Why does this matter? Because strings are the enemy of maintainability. Every string is a promise—a promise that somewhere else in your code, you'll parse this string correctly. Strings don't enforce contracts.
Type-safe routes enforce contracts at compile time.
Nav3 embraces Compose's core strength: composability. Your navigation isn't hidden in a separate graph definition—it's a composable function that you can reason about, test, and modify just like any other composable.
https://gist.github.com/AndroidPoet/43655c765ae1d06cd8962661e98adec7
This means your navigation logic lives in the same place as your UI logic. You can reason about it without context switching. Testing is straightforward. Debugging is easier.
Navigation shouldn't lose your state. Compose Multiplatform Navigation 3 preserves view state across navigation events using SavedStateHandle. When you navigate away and come back, your state is restored automatically.
At the heart of Nav3 is the Route. This is a sealed class that represents every possible destination in your application.
https://gist.github.com/AndroidPoet/60e20089cffb7ee6c3cdffa5e377b049
The beauty here is that this sealed class becomes your source of truth for navigation. Every destination in your app is represented here. Every argument is typed. Every refactoring can be tracked by the compiler.
The NavController is the object that manages navigation state. It maintains the back stack and allows you to navigate, pop, and query the current state.
https://gist.github.com/AndroidPoet/076e844418d162f129e6a26cb508963b
The NavController maintains this back stack internally. When you navigate to a new route, it's pushed onto the stack. When you pop, it's removed. The back button automatically calls popBackStack().
The NavHost composable is where everything comes together. It's a container that manages the navigation graph and renders the appropriate screen based on the current route.
https://gist.github.com/AndroidPoet/a0a840a3f0583ec8a0a9070ac9850009
Here's what's happening under the hood:
- Start Destination: The app starts at
Route.Home - Back Stack: NavHost maintains a back stack of routes
- Composition: At any given time, NavHost renders the composable corresponding to the top of the back stack
- State Preservation: As you navigate back and forth, the state of screens is preserved
Now that you understand the architecture, let's explore patterns you'll actually use in production apps.
This is one of the most common patterns in modern mobile apps. Think of apps like Twitter, Instagram, or TikTok. Each tab has its own independent navigation stack.
The key insight: when you switch tabs, you should be right where you left off.
https://gist.github.com/AndroidPoet/d40a89b1c634f3a3d610e268f3812f3e
Why this pattern works so well:
- Each tab remembers its position in its own navigation stack
- Switching tabs doesn't destroy the state of the previous tab
- Back button goes to the previous screen in the current tab stack
- Double-tap to scroll is a common pattern handled separately via events
- Memory efficient with 4 NavControllers rendered only when visible
Often, you need one screen to open another and receive a result. Examples: a picker that returns a selected value, a date picker, an image picker, or a payment screen.
https://gist.github.com/AndroidPoet/897b1546de01d1f54b27a1678cc6dd1c
Why this approach is superior:
- Type-safe result passing
- Results survive configuration changes
- No callback hell or tight coupling
- Clear separation of concerns
- Works across process boundaries
Passing complex objects:
https://gist.github.com/AndroidPoet/43ad92e77d4d0839a9d1cd022ef9c9fc
Deep links allow users to jump directly to a specific screen from an external source: a push notification, an email link, or a web search result.
https://gist.github.com/AndroidPoet/6bc802f40e863b0dce38dfb7ee36aca3
Deep linking best practices:
- Always include a fallback to Route.Home
- Use
popUpTo()to clear the back stack when entering via deep link - Set
launchSingleTop = trueto prevent duplicate instances - Validate the data before navigating
- Log failures for debugging
Different authentication states require different navigation flows. A logged-out user should see the login screen. A user with incomplete onboarding should see the onboarding flow. Only fully onboarded users see the main app.
https://gist.github.com/AndroidPoet/290e7c2013c2c8ecde91af577dc063c2
Why this pattern is critical:
- Security: Logged-out users never see the main app
- User experience: Onboarding is enforced before the main experience
- State consistency: The UI always reflects the actual authentication state
- Clear flows: Each auth state has an explicit, well-defined navigation path
- Recovery: If authentication fails, users are routed back to login
Navigation logic is critical and should be thoroughly tested.
https://gist.github.com/AndroidPoet/9c56dc59fb93379c48ab4817a6edc649
Navigation isn't just about moving between screens—it's about doing so elegantly.
https://gist.github.com/AndroidPoet/1a35cd49b8f6014119d36fb2c769fe84
https://gist.github.com/AndroidPoet/689a1231abaf77e577f269c0591fba90
https://gist.github.com/AndroidPoet/440dadc93c66bcaabf585d37107f3ecf
https://gist.github.com/AndroidPoet/b4d29bc3faab32f7ae78d76063852171
Problem: When you navigate away and back, your composable state is lost.
Solution: Use SavedStateHandle for state that should persist, or use ViewModel:
https://gist.github.com/AndroidPoet/87f5f1a4401d077c460d2d3996f5d0d1
Problem: Passing lambdas to screen composables can create retained references.
Solution: Use rememberCallback or pass stable functions:
https://gist.github.com/AndroidPoet/ca81d6c35c8a50b689705699f4700bfe
Problem: Receiving a deep link for a route that was removed.
Solution: Always handle parsing errors:
https://gist.github.com/AndroidPoet/67424c81ca5bd2476358e0bd8d66d9f1
Problem: Back button doesn't navigate on some screens.
Solution: Ensure you have BackHandler set up:
https://gist.github.com/AndroidPoet/020f372848b621a8ae5943204be0ff24
For large apps with many routes, consider lazy loading:
https://gist.github.com/AndroidPoet/ab93585b2524263794770e1d373754a9
https://gist.github.com/AndroidPoet/148af102e7aacf7e33a685ca8733fa6e
Navigation 3 elevates navigation from a second-class concern to a first-class citizen in Compose Multiplatform development. By making routes type-safe, composable, and state-aware, it enables developers to build complex, multi-screen applications with confidence.
The nav3-recipes project demonstrates that these patterns aren't theoretical—they work in the real world, scale to complex applications, and provide a foundation for building world-class experiences across iOS, Android, Desktop, and Web.
The future of multiplatform development is here. And it navigates beautifully.
Resources for Further Learning:
- nav3-recipes repository: https://github.com/terrakok/nav3-recipes
- Compose Multiplatform documentation: https://www.jetbrains.com/help/kotlin-multiplatform/
- Kotlinx.serialization guide: https://github.com/Kotlin/kotlinx.serialization
- SavedStateHandle documentation: Android Architecture Components
- Deep linking best practices: Google Android Developer guides