NavigationStack in production: deep links, programmatic navigation, and state that survives
How to use NavigationStack for real apps: wiring deep links, managing programmatic navigation paths, and keeping state restoration reliable across interruptions.
On this page
NavigationStack replaced NavigationView in iOS 16, and it was a real improvement: type-safe destinations, value-based path management, and proper tooling for programmatic navigation.
But the documentation shows you toy examples. Three buttons stacked vertically, each pushing a detail view. That is not what production looks like.
Production is:
- a tab bar with four tabs, each holding its own stack
- deep links that need to navigate to a specific screen in tab 3
- push notifications that should open a modal over whatever the user is looking at
- state restoration after the system kills your app in the background
- sheets and full-screen covers that interact with the navigation path
- a settings screen that sometimes appears as a pushed screen and sometimes as a sheet
None of that is hard once you build around a few rules. But if you wing it, you get navigation state that fights you at every turn.
1. One path per stack, one owner per path
The most common mistake is letting multiple objects mutate the navigation path independently.
A NavigationPath is mutable state. If your view model appends to it, your router appends to it, and your deep-link handler also appends to it, you have a race condition pretending to be a UI.
Pick one owner.
@MainActor
@Observable
final class HomeNavigation {
var path = NavigationPath()
func navigate(to destination: HomeDestination) {
path.append(destination)
}
func popToRoot() {
path = NavigationPath()
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
}Inject that object into the subtree that owns the stack. Only that object mutates the path. Everything else — button taps, deep links, programmable navigation — goes through its methods.
This sounds restrictive. It is. That restriction is what prevents your navigation state from becoming an unreadable mess three months in.
2. Use typed destinations, not raw views
NavigationStack gives you two ways to push: value-based and view-based.
View-based navigation (NavigationLink(destination:)) couples the destination view to the call site. That works for static hierarchies. It falls apart when you need to navigate from a deep link, a notification, or a programmatic trigger.
Value-based navigation is almost always the better choice in production.
// Define your destinations as values
enum HomeDestination: Hashable, Sendable {
case articleDetail(id: String)
case search(query: String?)
case readingList
case userProfile(id: String)
}
// Register them at the stack level
NavigationStack(path: $nav.path) {
HomeView()
.navigationDestination(for: HomeDestination.self) { destination in
switch destination {
case .articleDetail(let id):
ArticleDetailView(articleID: id)
case .search(let query):
SearchView(initialQuery: query)
case .readingList:
ReadingListView()
case .userProfile(let id):
UserProfileView(userID: id)
}
}
}Now any code that calls nav.navigate(to: .articleDetail(id: "42")) works the same way — a button tap, a deep link resolver, or a test. The destination view is built in one place, with one set of parameters.
If you have a tab bar app, each tab gets its own destination enum and its own navigation owner. That keeps the type space manageable.
enum TabDestination: Hashable {
case home(HomeDestination)
case search(SearchDestination)
case library(LibraryDestination)
case profile(ProfileDestination)
}3. Deep links drive the path, not the views
If you already have a deep-link pipeline (and if you do not, start with routes, not raw URLs), the navigation layer's job is simple: turn a resolved route into a path mutation.
@MainActor
@Observable
final class AppRouter {
var selectedTab: AppTab = .home
var homeNav = HomeNavigation()
var searchNav = SearchNavigation()
var profileNav = ProfileNavigation()
func open(_ route: AppRoute) {
switch route {
case .home:
selectedTab = .home
homeNav.popToRoot()
case .article(let id, _):
selectedTab = .home
homeNav.navigate(to: .articleDetail(id: id))
case .search(let query):
selectedTab = .search
searchNav.navigate(to: .searchResults(query: query))
case .profile(let userID):
selectedTab = .profile
profileNav.navigate(to: .userProfile(id: userID))
case .settings:
selectedTab = .profile
profileNav.navigate(to: .settings)
}
}
}The key principle: deep links should never construct or manipulate views directly. They talk to the router. The router talks to the navigation owner. The navigation owner mutates the path. The path drives the UI.
If you keep that chain intact, deep links, button taps, and programmatic navigation all produce identical behavior. That is the goal.
4. Sheets and covers are not the path — until you want them to be
sheet and fullScreenCover are presentation modifiers driven by optional or identified state, not by NavigationPath.
That is usually fine. But there is one scenario that trips up every team: presenting a sheet from a deep link while the navigation stack is mid-transition.
If the system is animating a push and you trigger a sheet in the same runloop, one of two things happens:
- the sheet appears, then disappears when the push animation completes
- the push silently fails and the sheet stays
Neither is good.
The fix is to sequence the presentation:
@Observable
final class ModalPresenter {
var pendingSheet: SheetRoute?
func present(_ route: SheetRoute) {
// Defer to the next runloop if a navigation transition is in progress
pendingSheet = route
}
}
// In the view:
.sheet(item: $presenter.pendingSheet) { route in
switch route {
case .settings:
SettingsSheet()
case .paywall(let source):
PaywallView(source: source)
}
}If you are navigating and presenting a modal from the same trigger (say, a deep link that should switch tabs and show a paywall), do the tab switch first, then present the modal after the runloop settles:
func handleDeepLink(_ route: AppRoute) {
// Step 1: navigate to the right tab/stack
router.open(route)
// Step 2: present modal on the next cycle if needed
if case .paywall(let source) = route {
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
presenter.present(.paywall(source: source))
}
}
}A 50ms delay is not elegant. But UIKit has had this sequencing problem since 2014, and NavigationStack inherits the same rendering pipeline. The alternative — building a modal queue with explicit readiness checks — is cleaner if you have many modal triggers, but overkill for most apps.
5. State restoration: save the path, not the journey
NavigationPath supports Codable out of the box. That means you can persist where the user was and restore it after a cold launch.
But do not persist everything. Save the meaningful parts of the path, not transient state.
extension NavigationPath {
func snapshot() -> Data? {
guard let codable = codable else { return nil }
return try? JSONEncoder().encode(codable)
}
static func restore(from data: Data) -> NavigationPath? {
guard let codable = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
) else {
return nil
}
return NavigationPath(codable)
}
}Store the snapshot when the app goes to the background or when the path changes (debounced). Restore it when the scene reconnects and the root view appears.
A few rules:
- Do not restore if the user is logged out. A restored path to a profile screen behind a login wall is a crash waiting to happen.
- Do not restore if the app version changed. Destination types may have changed. A decode failure is your safety net.
- Validate the restored path against current state. If the article ID in the path was deleted, you want to pop it or show an error, not crash.
func restoreIfNeeded() {
guard
let data = UserDefaults.standard.data(forKey: "home_nav_snapshot"),
session.isLoggedIn
else {
return
}
// Version-check the snapshot
let lastVersion = UserDefaults.standard.string(forKey: "nav_snapshot_version")
guard lastVersion == appVersion else {
UserDefaults.standard.removeObject(forKey: "home_nav_snapshot")
return
}
if let restored = NavigationPath.restore(from: data) {
path = restored
}
}State restoration is not a nice-to-have. iOS will reclaim your app's memory when it needs to, and users expect to return where they left off. If your navigation state evaporates every time the system kills your app in the background, users notice — and they interpret it as bugs.
6. Tab bar + NavigationStack: the two patterns that work
Every tab bar app needs to decide: shared router or per-tab routers.
Pattern A: per-tab routers (recommended for most apps)
Each tab owns its navigation state independently. Deep links switch to the target tab and then drive that tab's router.
struct RootView: View {
@Environment(Router.self) private var router
var body: some View {
TabView(selection: $router.selectedTab) {
NavigationStack(path: $router.homeNav.path) {
HomeView()
.navigationDestination(for: HomeDestination.self) { dest in
HomeDestinationView(destination: dest)
}
}
.tabItem { Label("Home", systemImage: "house") }
.tag(AppTab.home)
NavigationStack(path: $router.profileNav.path) {
ProfileView()
.navigationDestination(for: ProfileDestination.self) { dest in
ProfileDestinationView(destination: dest)
}
}
.tabItem { Label("Profile", systemImage: "person") }
.tag(AppTab.profile)
}
}
}This pattern is boring. Each tab is self-contained. Deep links resolve to a tab + destination pair. State restoration works per-tab. You never need to reason about cross-tab interactions.
Use this pattern unless you have a specific reason not to.
Pattern B: shared path with tab segments
Some apps model the entire app as a single path with tab segments. This is more complex, harder to restore, and almost never worth it unless your navigation is genuinely non-linear (e.g., a wizard that can start from multiple tabs).
Skip it unless you can articulate why Pattern A does not work for your app.
7. Avoid these common traps
Trap 1: Multiple NavigationStacks in a hierarchy
If you nest NavigationStacks — a stack inside a tab inside another stack — the system picks one to drive navigation bar rendering, and it may not be the one you expect. Keep NavigationStacks at the root of each tab. Use sheets or covers for modal flows.
Trap 2: Binding leaks
$path is a binding. If you pass that binding deep into child views and let them mutate it directly, you lose the single-owner rule. Pass the navigation object (or its methods) instead of the raw binding.
Trap 3: Forgetting Hashable conformance
Every value you push onto a NavigationPath must be Hashable (and ideally Sendable). If you use structs with reference-type properties, hashing breaks silently. Use value types for destinations.
Trap 4: Animating path changes during view updates
Calling path.append() inside body or during a withAnimation that also triggers a view update can produce visual glitches. Mutate the path in response to user actions or explicit callbacks, not as a side effect of view rendering.
Trap 5: Ignoring the iPad split-view behavior
On iPad, NavigationStack renders as a split view by default. If your navigation model assumes a single-column push, test early on iPad. You may need .navigationSplitViewStyle(.stack) to force single-column behavior where appropriate.
8. Test the navigation logic, not the views
Navigation code is some of the most testable code in your app — if you structured it correctly.
The navigation owner holds the path. The path is just an array of values. You can test that calling navigate(to:) produces the expected path without rendering any SwiftUI.
import Testing
@testable import App
struct HomeNavigationTests {
@Test
func navigateToArticleAppendsToPath() {
let nav = HomeNavigation()
nav.navigate(to: .articleDetail(id: "42"))
#expect(nav.path.count == 1)
}
@Test
func popToRootClearsPath() {
let nav = HomeNavigation()
nav.navigate(to: .articleDetail(id: "1"))
nav.navigate(to: .readingList)
nav.popToRoot()
#expect(nav.path.isEmpty)
}
}
struct AppRouterTests {
@Test
func deepLinkToArticleSwitchesTabAndNavigates() {
let router = AppRouter()
router.open(.article(id: "99", referrer: nil))
#expect(router.selectedTab == .home)
#expect(router.homeNav.path.count == 1)
}
@Test
func deepLinkToProfileSwitchesToProfileTab() {
let router = AppRouter()
router.open(.profile(userID: "u_1"))
#expect(router.selectedTab == .profile)
#expect(!router.profileNav.path.isEmpty)
}
}These tests run in milliseconds. They catch regressions when you refactor routing, add new destinations, or change how deep links resolve. If a navigation refactor breaks a test like this, you find out before the build finishes — not when QA files a bug.
9. A pragmatic checklist
Before shipping a NavigationStack setup to production, confirm:
- [ ] One owner per navigation path
- [ ] All navigation goes through typed destination values
- [ ] Deep links talk to the router, not to views
- [ ] Sheets and covers are sequenced if they interact with path changes
- [ ] Navigation state is persisted and restored with version checking
- [ ] Each tab in a tab bar has its own stack and owner
- [ ] No nested NavigationStacks
- [ ] iPad split-view behavior is tested
- [ ] Navigation logic has unit tests
- [ ] Restored paths are validated against current app state
Navigation is infrastructure. It should be boring, predictable, and testable. If your navigation code feels clever, that is usually a warning sign.