If you've been building iOS apps with SwiftUI, you've probably used NavigationView at some point. But as of iOS 16, Apple officially deprecated NavigationView and introduced NavigationStack as its modern replacement. In 2026, if you're still using NavigationView, you're working with legacy code.
In this comprehensive guide, I'll walk you through everything you need to know about NavigationStack -- from basic setup to advanced patterns like deep linking and the Coordinator pattern.
1. Why NavigationStack?
NavigationView had several problems that made complex navigation difficult:
- Ambiguous behavior on iPad (it would switch between split and stack styles unpredictably)
- No programmatic navigation -- you couldn't push views from code without binding tricks
- Difficult deep linking -- navigating to a specific screen deep in the hierarchy was painful
- State management issues -- navigation state was scattered across views
NavigationStack solves all of these by giving you:
- A clear, single-column stack-based navigation
- Full programmatic control over the navigation path
- Type-safe navigation destinations
- Easy deep linking support
// The old way (deprecated)
NavigationView {
List {
NavigationLink("Go to Detail", destination: DetailView())
}
.navigationTitle("Home")
}
// The new way
NavigationStack {
List {
NavigationLink("Go to Detail", destination: DetailView())
}
.navigationTitle("Home")
}
The simplest migration is just replacing NavigationView with NavigationStack. But the real power comes from the new APIs.
2. Basic NavigationStack Setup
Here's how to set up a basic NavigationStack with NavigationLink:
struct ContentView: View {
let fruits = ["Apple", "Banana", "Cherry", "Dragonfruit", "Elderberry"]
var body: some View {
NavigationStack {
List(fruits, id: \.self) { fruit in
NavigationLink(fruit, value: fruit)
}
.navigationTitle("Fruits")
.navigationDestination(for: String.self) { fruit in
FruitDetailView(name: fruit)
}
}
}
}
struct FruitDetailView: View {
let name: String
var body: some View {
VStack(spacing: 20) {
Text(name)
.font(.largeTitle)
Text("This is the detail view for \(name)")
.foregroundStyle(.secondary)
}
.navigationTitle(name)
}
}
Notice the key difference: instead of passing a destination view directly to NavigationLink, we pass a value. Then we define a .navigationDestination(for:) modifier that tells SwiftUI how to build the destination view for that value type.
3. Programmatic Navigation with NavigationPath
This is where NavigationStack truly shines. Using NavigationPath, you can control the entire navigation stack from code:
struct AppView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
VStack(spacing: 20) {
Button("Go to Profile") {
path.append(Screen.profile)
}
Button("Go to Settings") {
path.append(Screen.settings)
}
Button("Deep Navigate: Profile > Edit") {
path.append(Screen.profile)
path.append(Screen.editProfile)
}
Button("Go Back to Root") {
path.removeLast(path.count)
}
}
.navigationTitle("Home")
.navigationDestination(for: Screen.self) { screen in
switch screen {
case .profile:
ProfileView(path: $path)
case .settings:
SettingsView()
case .editProfile:
EditProfileView()
}
}
}
}
}
enum Screen: Hashable {
case profile
case settings
case editProfile
}
With NavigationPath, you can:
- Push views by appending values
- Pop views by removing from the path
- Pop to root by clearing the entire path
- Deep link by appending multiple values at once
4. Passing Data Between Views
One of the best features of NavigationStack is type-safe data passing via .navigationDestination(for:). You can register multiple destination types:
struct ShopView: View {
@State private var path = NavigationPath()
let categories = [
Category(name: "Electronics", icon: "desktopcomputer"),
Category(name: "Clothing", icon: "tshirt"),
Category(name: "Books", icon: "book")
]
var body: some View {
NavigationStack(path: $path) {
List(categories) { category in
NavigationLink(value: category) {
Label(category.name, systemImage: category.icon)
}
}
.navigationTitle("Shop")
.navigationDestination(for: Category.self) { category in
CategoryDetailView(category: category, path: $path)
}
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
}
}
struct Category: Identifiable, Hashable {
let id = UUID()
let name: String
let icon: String
}
struct Product: Identifiable, Hashable {
let id = UUID()
let name: String
let price: Double
let category: String
}
struct CategoryDetailView: View {
let category: Category
@Binding var path: NavigationPath
let products: [Product] = [
Product(name: "MacBook Pro", price: 1999, category: "Electronics"),
Product(name: "iPhone 17", price: 999, category: "Electronics")
]
var body: some View {
List(products) { product in
NavigationLink(value: product) {
HStack {
Text(product.name)
Spacer()
Text("$\(product.price, specifier: "%.0f")")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle(category.name)
}
}
Each data type gets its own .navigationDestination(for:) handler. SwiftUI automatically routes to the correct view based on the type you push onto the path.
5. Deep Linking
Deep linking becomes trivial with NavigationStack. You can navigate to any screen in your app by manipulating the path:
class DeepLinkManager: ObservableObject {
@Published var path = NavigationPath()
func handleDeepLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host else { return }
// Reset to root first
path.removeLast(path.count)
switch host {
case "profile":
path.append(Screen.profile)
case "settings":
path.append(Screen.settings)
case "product":
if let productId = components.queryItems?.first(where: { $0.name == "id" })?.value {
path.append(Screen.profile)
path.append(ProductID(id: productId))
}
default:
break
}
}
}
struct RootView: View {
@StateObject private var deepLinkManager = DeepLinkManager()
var body: some View {
NavigationStack(path: $deepLinkManager.path) {
HomeView()
.navigationDestination(for: Screen.self) { screen in
switch screen {
case .profile: ProfileView(path: $deepLinkManager.path)
case .settings: SettingsView()
case .editProfile: EditProfileView()
}
}
.navigationDestination(for: ProductID.self) { productId in
ProductDetailView(productId: productId.id)
}
}
.onOpenURL { url in
deepLinkManager.handleDeepLink(url)
}
}
}
struct ProductID: Hashable {
let id: String
}
Now your app can handle URLs like myapp://product?id=123 and navigate directly to the product detail screen.
6. Navigation Patterns: Coordinator with NavigationStack
For larger apps, I recommend using a Coordinator pattern to centralize navigation logic:
@Observable
class AppCoordinator {
var path = NavigationPath()
// MARK: - Navigation Actions
func showProfile(for userId: String) {
path.append(Route.profile(userId))
}
func showSettings() {
path.append(Route.settings)
}
func showProductDetail(_ product: Product) {
path.append(Route.productDetail(product))
}
func goBack() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
// MARK: - Route Definitions
enum Route: Hashable {
case profile(String)
case settings
case productDetail(Product)
case editProfile
case notifications
}
// MARK: - View Builder
@ViewBuilder
func view(for route: Route) -> some View {
switch route {
case .profile(let userId):
ProfileView(userId: userId)
case .settings:
SettingsView()
case .productDetail(let product):
ProductDetailView(product: product)
case .editProfile:
EditProfileView()
case .notifications:
NotificationsView()
}
}
}
struct CoordinatedAppView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: AppCoordinator.Route.self) { route in
coordinator.view(for: route)
}
}
.environment(coordinator)
}
}
The Coordinator pattern gives you:
- Single source of truth for all navigation
- Testable navigation logic -- you can unit test the coordinator
- Decoupled views -- views don't need to know about each other
- Easy refactoring -- change navigation flow in one place
7. Common Mistakes
Here are the top 3 mistakes I see developers make with NavigationStack:
Mistake 1: Nesting NavigationStacks
// WRONG -- nested NavigationStacks cause double navigation bars
NavigationStack {
NavigationStack { // Don't do this!
Text("Hello")
}
}
// CORRECT -- use only one NavigationStack at the root
NavigationStack {
TabView {
HomeTab() // No NavigationStack inside
ProfileTab() // No NavigationStack inside
}
}
Mistake 2: Forgetting to Register navigationDestination
// WRONG -- this will silently fail
NavigationStack {
NavigationLink("Go", value: MyModel())
// Missing .navigationDestination(for: MyModel.self)!
}
// CORRECT
NavigationStack {
NavigationLink("Go", value: MyModel())
.navigationDestination(for: MyModel.self) { model in
DetailView(model: model)
}
}
Mistake 3: Using NavigationPath Without Binding
// WRONG -- creating a local path that is not bound
struct MyView: View {
var body: some View {
let path = NavigationPath() // This resets on every render!
NavigationStack(path: .constant(path)) {
Text("Broken")
}
}
}
// CORRECT -- use @State to persist the path
struct MyView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
Text("Working")
}
}
}
Wrapping Up
NavigationStack is a massive improvement over NavigationView. It gives you programmatic control, type-safe routing, easy deep linking, and works beautifully with SwiftUI's declarative paradigm.
If you're starting a new project in 2026, use NavigationStack from day one. If you have an existing project, plan a gradual migration -- start with new screens and migrate existing ones over time.
If you want production-ready SwiftUI templates with navigation already built in, check out my toolkit: https://pease163.github.io/digital-products/
What navigation patterns do you use in your SwiftUI apps? Let me know in the comments!
Top comments (0)