Skip to content
← Back to writing
Writing · Jan 2026

Stop Using NavigationView - Mastering NavigationStack in 2026

ios-developmentswiftuimobile-appsswift

If you are still wrapping your SwiftUI views in NavigationView in 2026, we need to talk.

Apple soft-deprecated NavigationView years ago. While it might still "work" for simple prototypes, it is a nightmare for scalable, real-world applications. If you want to handle push notifications, deep links, or complex onboarding flows, you must use NavigationStack.

In this guide, I’ll show you how to move from "Push-and-Pray" navigation to a robust, state-driven routing system.

The Problem with the Old Way

In the early days of SwiftUI, we used NavigationLink directly inside our lists. It looked innocent enough:

1// The Old Way (Don't do this anymore)
2NavigationView {
3 List(0..<5) { i in
4 // ❌ Problem: DetailView is initialized immediately for EVERY row!
5 NavigationLink("Go to Detail \(i)", destination: DetailView(i: i))
6 }
7}
8

Two things go wrong here. First, NavigationLink(destination:) builds every DetailView up front, even for rows nobody taps. Second, navigation lives inside the views. There is no single value that says "the user is on screen X", so you can't push a screen from a notification, restore it from a deep link, or pop back to the root from code.

The New Way: navigation as data

NavigationStack (iOS 16+) flips this around. You describe where a link goes with a value, and you describe how to show that value once, with navigationDestination(for:):

1struct ContentView: View {
2 var body: some View {
3 NavigationStack {
4 List(0..<5) { i in
5 // ✅ Only a value is created here. DetailView is built on tap.
6 NavigationLink("Go to Detail \(i)", value: i)
7 }
8 .navigationDestination(for: Int.self) { i in
9 DetailView(i: i)
10 }
11 }
12 }
13}
14

The destination view is now built lazily, and the link only carries an Int.

Step 1: Model your routes

Real apps have more than one kind of screen. Put them in one Hashable enum so every place in the app can name a destination the same way:

1enum Route: Hashable {
2 case profile(userID: String)
3 case settings
4 case order(id: Int)
5}
6

Step 2: Own the path in a router

Bind the stack to an array of routes. Whoever owns that array controls navigation:

1import Observation
2
3@Observable
4final class Router {
5 var path: [Route] = []
6
7 func push(_ route: Route) { path.append(route) }
8 func pop() { _ = path.popLast() }
9 func popToRoot() { path.removeAll() }
10}
11

@Observable needs iOS 17. On iOS 16, make it an ObservableObject with @Published var path.

Step 3: Wire the stack to the router

1struct RootView: View {
2 @State private var router = Router()
3
4 var body: some View {
5 NavigationStack(path: $router.path) {
6 HomeView()
7 .navigationDestination(for: Route.self) { route in
8 switch route {
9 case .profile(let userID): ProfileView(userID: userID)
10 case .settings: SettingsView()
11 case .order(let id): OrderView(id: id)
12 }
13 }
14 }
15 .environment(router)
16 }
17}
18

Any child view can now navigate without knowing about its parents:

1struct HomeView: View {
2 @Environment(Router.self) private var router
3
4 var body: some View {
5 Button("Open settings") { router.push(.settings) }
6 }
7}
8

Step 4: Deep links and notifications for free

Because the whole stack is just [Route], opening a screen from outside the UI means setting an array. For a URL like myapp://order/42:

1.onOpenURL { url in
2 guard url.host == "order",
3 let id = Int(url.lastPathComponent) else { return }
4 router.path = [.order(id: id)]
5}
6

The same works when a push notification arrives: parse the payload into a Route and assign the path. To jump several screens deep, assign several routes, for example [.profile(userID: "me"), .settings].

Mixed value types: NavigationPath

If different features push unrelated types and you don't want one big enum, use NavigationPath, a type-erased path that accepts any Hashable value:

1@State private var path = NavigationPath()
2
3NavigationStack(path: $path) {
4 FeedView()
5 .navigationDestination(for: Post.self) { PostView(post: $0) }
6 .navigationDestination(for: User.self) { UserView(user: $0) }
7}
8

An enum is easier to read and test, so I start there and only reach for NavigationPath when features need to stay independent.

Quick migration checklist

  • Replace NavigationView with NavigationStack. For sidebar layouts on iPad and Mac, use NavigationSplitView.
  • Change NavigationLink(destination:) to NavigationLink(value:).
  • Add one navigationDestination(for:) per value type, high in the hierarchy rather than inside lists.
  • Move the path into a router you can reach from anywhere.
  • Route deep links and notifications by assigning the path.

If you're building an iOS app and want routing like this from day one, that's the kind of work I do.