Skip to content
·4 min read

NavigationStack Finally Made SwiftUI Navigation Sane: Here's How I Use It

Typed routes, programmatic pushes, deep links, and getting rid of NavigationView for good. The patterns I use in every shipped SwiftUI app.

SwiftUISwiftiOSNavigation

If you fought with NavigationView between 2019 and 2022, you know the pain: NavigationLink isActive deprecations, programmatic navigation that only worked by accident, and deep links that required UIKit duct tape.

NavigationStack (iOS 16+) fixed this. Not cosmetically. It changed the mental model: navigation became a value you own instead of a side effect you chase.

Step 1: Replace NavigationView with NavigationStack

The swap is mechanical:

// Old
NavigationView {
    List(items) { item in
        NavigationLink(item.name, destination: ItemDetail(item: item))
    }
    .navigationTitle("Items")
}
 
// New
NavigationStack {
    List(items) { item in
        NavigationLink(item.name, value: item)
    }
    .navigationTitle("Items")
}

Notice the link no longer carries a destination. It carries a value. Where that value goes is defined elsewhere, which is the whole trick.

Step 2: Drive navigation with a typed path

The simplest stack owns an array of the values you navigate with:

struct ItemsView: View {
    @State private var path: [Item] = []
 
    var body: some View {
        NavigationStack(path: $path) {
            List(items) { item in
                NavigationLink(item.name, value: item)
            }
            .navigationDestination(for: Item.self) { item in
                ItemDetail(item: item)
            }
        }
    }
}

Now navigation is just array mutation:

path.append(item)          // push
path.removeLast()          // pop
path.removeAll()           // pop to root
path = [itemA, itemB]      // push two screens at once

If your screens mix types, use NavigationPath instead of a typed array. It is a type-erased wrapper with the same append/remove API. I start with a typed array and only reach for NavigationPath when a flow genuinely mixes value types, because typed arrays catch bugs at compile time.

Step 3: Register destinations by type, not by view

navigationDestination(for:) maps a Hashable type to a screen. One registration per type, anywhere inside the stack:

.navigationDestination(for: Item.self) { item in
    ItemDetail(item: item)
}
.navigationDestination(for: Category.self) { category in
    CategoryView(category: category)
}

This scales far better than link-scoped destinations. When every screen can push an Item, you define what an Item opens exactly once. Adding a "recently viewed" row to a settings screen becomes a two-line change instead of threading destination closures through five views.

Step 4: Handle deep links by seeding the path

This is where NavigationStack earns its keep. A deep link like myapp://orders/42 used to mean timers waiting for the stack to exist, then programmatic pushes one by one.

Now a deep link is a value assignment:

func handleDeepLink(_ url: URL) -> [Route] {
    guard url.pathComponents.count >= 2 else { return [] }
    switch url.host {
    case "orders":
        return [.orders, .order(id: url.pathComponents[1])]
    default:
        return []
    }
}
 
// in the view
.onOpenURL { url in
    path = handleDeepLink(url)
}

SwiftUI pushes every intermediate screen with animation. The user can pop back through the whole hierarchy, and state on each screen loads in order. One array, no hacks.

One catch: onOpenURL can fire before your stack finishes appearing on cold start. If the path assignment gets lost on first launch, seed it in onAppear from a stored URL instead, or hold the path in a router object that exists above the stack.

Step 5: Keep state alive across tabs and rebuilds

Two rules I learned from shipping this in production apps:

Register destinations outside lazy containers. A navigationDestination inside a LazyVStack or ForEach row registers only when that row renders. Scroll fast, tap a link that appeared before its registration, and nothing happens. Put registrations directly on the stack's root content.

Give each tab its own path that lives above the tab view.

@Observable final class Router {
    var itemsPath: [Item] = []
    var ordersPath: [Order] = []
}
 
// App root
TabView {
    Tab("Items", systemImage: "shippingbox") {
        NavigationStack(path: $router.itemsPath) { ... }
    }
    Tab("Orders", systemImage: "doc.text") {
        NavigationStack(path: $router.ordersPath) { ... }
    }
}

If the router lives in the environment, any view can navigate ("open this order") without passing closures down five levels. Switching tabs preserves each stack because the paths never leave memory.

The migration checklist

  1. NavigationView becomes NavigationStack. Delete every isActive/isDetailLink workaround.
  2. NavigationLink(destination:) becomes NavigationLink(value:).
  3. Add one navigationDestination(for:) per model type, on the root.
  4. Replace programmatic push hacks with path mutations.
  5. Deep links become path seeds. Test cold start on a real device.
  6. Move paths into an @Observable router if you have more than one stack.

The end result is navigation you can unit test: build the path array, assert it equals what you expect. No UI automation required to know your deep link works.

Want a SwiftUI app built with navigation that holds up in production, deep links and all? That is exactly what I do. Get in touch and tell me about your app.