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:
// The Old Way (Don't do this anymore)
NavigationView {
List(0..<5) { i in
// โ Problem: DetailView is initialized immediately for EVERY row!
NavigationLink("Go to Detail \(i)", destination: DetailView(i: i))
}
}
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:):
struct ContentView: View {
var body: some View {
NavigationStack {
List(0..<5) { i in
// โ
Only a value is created here. DetailView is built on tap.
NavigationLink("Go to Detail \(i)", value: i)
}
.navigationDestination(for: Int.self) { i in
DetailView(i: i)
}
}
}
}
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:
enum Route: Hashable {
case profile(userID: String)
case settings
case order(id: Int)
}
Step 2: Own the path in a router
Bind the stack to an array of routes. Whoever owns that array controls navigation:
import Observation
@Observable
final class Router {
var path: [Route] = []
func push(_ route: Route) { path.append(route) }
func pop() { _ = path.popLast() }
func popToRoot() { path.removeAll() }
}
@Observable needs iOS 17. On iOS 16, make it an ObservableObject with @Published var path.
Step 3: Wire the stack to the router
struct RootView: View {
@State private var router = Router()
var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .profile(let userID): ProfileView(userID: userID)
case .settings: SettingsView()
case .order(let id): OrderView(id: id)
}
}
}
.environment(router)
}
}
Any child view can now navigate without knowing about its parents:
struct HomeView: View {
@Environment(Router.self) private var router
var body: some View {
Button("Open settings") { router.push(.settings) }
}
}
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:
.onOpenURL { url in
guard url.host == "order",
let id = Int(url.lastPathComponent) else { return }
router.path = [.order(id: id)]
}
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:
@State private var path = NavigationPath()
NavigationStack(path: $path) {
FeedView()
.navigationDestination(for: Post.self) { PostView(post: $0) }
.navigationDestination(for: User.self) { UserView(user: $0) }
}
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
NavigationViewwithNavigationStack. For sidebar layouts on iPad and Mac, useNavigationSplitView. - Change
NavigationLink(destination:)toNavigationLink(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.
Originally published at iahtisham.com.
I'm Ahtisham, an independent iOS developer building native Swift apps. More from me:
- ๐ Portfolio and client work: iahtisham.com
- ๐ธ AppPriceKit: free App Store subscription prices by country
- ๐งพ JustInvoice: invoice maker for iPhone
- ๐ ScanIt: document scanner and PDF for iPhone
- ๐ NUR: prayer times, offline Qur'an and adhkar, free with no ads
- ๐ข PrismCode Labs
Find me on X @iAhtishamDev ยท GitHub ยท LinkedIn ยท Upwork
Top comments (0)