Navigation has historically been one of SwiftUI's roughest edges. Early versions gave us NavigationView and NavigationLink, which worked fine for demos but completely fell apart in real apps — broken back buttons, impossible deep linking, and navigation state scattered across dozens of views. It was, frankly, a mess.
That changed with NavigationStack in iOS 16, and by iOS 26 the navigation system has matured into something genuinely production-ready. The key insight? Navigation is no longer about pushing views. It's about pushing values.
In this guide, you'll build a complete navigation architecture from scratch. We'll start with the basics of NavigationStack, then layer on type-safe routing with enums, a centralized router using @Observable, deep linking from URLs and push notifications, state restoration across app launches, and NavigationSplitView for iPad and Mac. Every code example compiles and runs on iOS 26 with Swift 6.2.
Prerequisites
- Xcode 26 or later
- iOS 26 SDK or a compatible simulator
- Basic familiarity with SwiftUI views and state management
From NavigationView to NavigationStack
Before we dive in, let's be clear about what replaced what. NavigationView was deprecated in iOS 16. It still compiles, but Apple no longer fixes bugs in it, and it doesn't support programmatic navigation or deep linking in any meaningful way.
NavigationStack is the direct replacement for single-column, push-based navigation — the kind you see on iPhone. NavigationSplitView handles multi-column layouts for iPad and Mac. We'll cover both.
If your app still uses NavigationView, the migration is actually pretty straightforward: replace NavigationView with NavigationStack and swap any NavigationLink with destination closures for the new value-driven API described below.
Your First NavigationStack
The simplest NavigationStack wraps a list of navigation links:
struct ContentView: View {
let fruits = ["Apple", "Banana", "Cherry"]
var body: some View {
NavigationStack {
List(fruits, id: \.self) { fruit in
NavigationLink(fruit, value: fruit)
}
.navigationTitle("Fruits")
.navigationDestination(for: String.self) { fruit in
Text("You selected \(fruit)")
.font(.largeTitle)
}
}
}
}
Two things to notice here. First, the NavigationLink pushes a value (a String), not a destination view. Second, navigationDestination(for:) declares how to render that value. This separation is the foundation of everything that follows — it means you can push the same value from anywhere in your app and always get consistent behavior.
Value-Driven Navigation: The Mental Model
The old API coupled navigation triggers to destination views. If you wanted to navigate to a ProfileView, you had to import it and instantiate it right at the call site. This created tight coupling, made deep linking nearly impossible, and scattered navigation logic across every view.
The new API decouples them entirely. You push a value onto a path. Somewhere up the view hierarchy, a navigationDestination(for:) modifier knows how to turn that value into a view. The NavigationLink doesn't need to know what view it's navigating to — it only knows what data to push.
This isn't just an API difference. It's a fundamentally different way of thinking about navigation.
Your navigation state becomes a plain array of values that you can inspect, serialize, test, and manipulate programmatically. Once that clicks, you'll wonder how we ever lived without it.
Type-Safe Routing with Enums
Pushing raw strings works for demos, but production apps need structure. Define your routes as a Hashable enum:
enum Route: Hashable {
case detail(id: Int)
case settings
case profile(username: String)
case editProfile
}
Now your NavigationStack uses this enum:
struct AppView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let id):
DetailView(itemId: id)
case .settings:
SettingsView()
case .profile(let username):
ProfileView(username: username)
case .editProfile:
EditProfileView()
}
}
}
}
}
This gives you compile-time safety, which is honestly one of my favorite things about this approach. Add a new screen? Add an enum case. The compiler forces you to handle it in the switch. Rename a parameter? The compiler catches every call site. No more runtime crashes from typos or missing routes.
NavigationPath for Heterogeneous Stacks
The typed array approach works well when all your routes share a single enum. But sometimes you need to push values of different types — maybe your app mixes Route values with raw integers or other Hashable types.
NavigationPath is a type-erased container that holds any Hashable value:
@State private var path = NavigationPath()
// Push different types
path.append(Route.settings)
path.append(42)
path.append("some-string")
You'll need separate navigationDestination(for:) modifiers for each type you push. NavigationPath also supports Codable serialization out of the box, which makes state restoration straightforward — more on that later.
For most apps, though, I'd recommend starting with a typed array of your Route enum. It's simpler, safer, and way easier to debug. Only reach for NavigationPath when you genuinely need heterogeneous navigation.
Building a Router with @Observable
In a small app, managing the path array with @State works fine. But as your app grows, you'll want to centralize navigation logic so that any view — or any non-view code like a push notification handler — can trigger navigation.
This is where the router pattern comes in. Create an @Observable class that owns the navigation path:
@Observable
@MainActor
final class Router {
var path: [Route] = []
func push(_ route: Route) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeAll()
}
func replaceStack(with routes: [Route]) {
path = routes
}
}
Notice this uses @Observable from Swift's Observation framework — not the legacy ObservableObject with @Published. The @Observable macro tracks property access per view, so only views that actually read path will re-render when it changes. That's a nice performance win you get for free.
Inject the router into the environment:
@main
struct MyApp: App {
@State private var router = Router()
var body: some Scene {
WindowGroup {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
RouteDestination(route: route)
}
}
.environment(router)
}
}
}
Any view can now navigate by pulling the router from the environment:
struct ItemRow: View {
let item: Item
@Environment(Router.self) private var router
var body: some View {
Button(item.title) {
router.push(.detail(id: item.id))
}
}
}
This is enormously powerful. Navigation logic lives in one place. Views don't need to know about NavigationLink or even SwiftUI's navigation system — they just tell the router where to go. This makes views far more reusable and your navigation fully testable.
Handling Sheets and Full-Screen Covers
Not all navigation is push-based. Sheets and full-screen covers are presentation styles that exist alongside the navigation stack, and your router can manage these too:
@Observable
@MainActor
final class Router {
var path: [Route] = []
var sheet: Route?
var fullScreenCover: Route?
func push(_ route: Route) {
path.append(route)
}
func present(_ route: Route, style: PresentationStyle = .sheet) {
switch style {
case .sheet:
sheet = route
case .fullScreenCover:
fullScreenCover = route
}
}
func dismiss() {
sheet = nil
fullScreenCover = nil
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeAll()
}
func replaceStack(with routes: [Route]) {
path = routes
}
}
enum PresentationStyle {
case sheet
case fullScreenCover
}
Wire up the presentation modifiers at the root:
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
RouteDestination(route: route)
}
}
.sheet(item: $router.sheet) { route in
NavigationStack {
RouteDestination(route: route)
}
}
.fullScreenCover(item: $router.fullScreenCover) { route in
NavigationStack {
RouteDestination(route: route)
}
}
For this to compile, your Route enum needs to conform to Identifiable:
extension Route: Identifiable {
var id: Self { self }
}
Now any view can call router.present(.editProfile, style: .sheet) without knowing anything about SwiftUI's presentation mechanics. Clean and simple.
Deep Linking
Deep linking is where this whole NavigationStack architecture pays its biggest dividend. Because your navigation state is just an array of Route values, handling a deep link means parsing a URL into routes and setting the array. That's it.
Create a parser:
struct DeepLinkParser {
static func parse(_ url: URL) -> [Route]? {
guard url.scheme == "myapp" else { return nil }
let components = url.pathComponents.filter { $0 != "/" }
switch url.host {
case "detail":
guard let idString = components.first,
let id = Int(idString) else { return nil }
return [.detail(id: id)]
case "profile":
guard let username = components.first else { return nil }
return [.profile(username: username)]
case "settings":
return [.settings]
case "profile-edit":
guard let username = components.first else { return nil }
return [.profile(username: username), .editProfile]
default:
return nil
}
}
}
Wire it into your app:
WindowGroup {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
RouteDestination(route: route)
}
}
.environment(router)
.onOpenURL { url in
if let routes = DeepLinkParser.parse(url) {
router.replaceStack(with: routes)
}
}
}
The URL myapp://profile-edit/johndoe navigates to John's profile and then pushes the edit screen on top. The same mechanism works for push notifications, widgets, Spotlight results, and App Intents.
Notice the deep link for profile-edit pushes two routes. This is one of NavigationStack's superpowers: you can construct any arbitrary stack depth from a single URL. Try doing that with the old NavigationView — I'll wait.
State Restoration
Users expect to return to exactly where they left off when they reopen your app. With NavigationStack and Codable routes, this is almost free.
First, make your Route enum Codable:
enum Route: Hashable, Codable {
case detail(id: Int)
case settings
case profile(username: String)
case editProfile
}
Then persist and restore using SceneStorage:
struct RootView: View {
@Environment(Router.self) private var router
@SceneStorage("navigationPath") private var savedPath: Data?
var body: some View {
@Bindable var router = router
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
RouteDestination(route: route)
}
}
.task {
if let data = savedPath,
let decoded = try? JSONDecoder().decode([Route].self, from: data) {
router.replaceStack(with: decoded)
}
}
.onChange(of: router.path) { _, newPath in
savedPath = try? JSONEncoder().encode(newPath)
}
}
}
When the app gets terminated by the system, SceneStorage persists automatically. On next launch, the saved path is decoded and the entire navigation stack is reconstructed — the user lands right back where they were. It's one of those features that feels like magic the first time you see it work.
NavigationSplitView for iPad and Mac
On iPhone, NavigationStack provides a single-column push navigation experience. But on iPad and Mac, users expect a sidebar-driven, multi-column layout. NavigationSplitView delivers exactly this while automatically collapsing to a single column on compact size classes (like iPhone).
Here's a two-column layout:
struct SplitView: View {
@State private var selectedCategory: Category?
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
Label(category.title, systemImage: category.icon)
}
.navigationTitle("Categories")
} detail: {
if let category = selectedCategory {
CategoryDetailView(category: category)
} else {
ContentUnavailableView(
"Select a Category",
systemImage: "sidebar.left",
description: Text("Choose a category from the sidebar.")
)
}
}
}
}
On iPad, this renders as a sidebar on the left and a detail area on the right. On iPhone, it collapses into a single NavigationStack where tapping a category pushes the detail view. You don't have to do anything special — SwiftUI handles the adaptation.
Embedding NavigationStack in the Detail Column
You can embed a NavigationStack inside the detail column for deeper drill-down navigation:
NavigationSplitView {
// sidebar
} detail: {
if let category = selectedCategory {
NavigationStack(path: $router.path) {
CategoryDetailView(category: category)
.navigationDestination(for: Route.self) { route in
RouteDestination(route: route)
}
}
}
}
This gives you the best of both worlds: sidebar-based top-level navigation and push-based drill-down within each section. It's the pattern I reach for in most of my iPad apps.
Three-Column Layouts
For three-column layouts — common in email clients or file browser apps — add a content column:
NavigationSplitView {
// sidebar: mailboxes
} content: {
// middle: message list
} detail: {
// right: message detail
}
Controlling Column Visibility
You can control which columns are visible with a binding:
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
NavigationSplitView(columnVisibility: $columnVisibility) {
// sidebar
} detail: {
// detail
}
The options are .automatic (system decides based on device and orientation), .all (show every column), .doubleColumn (sidebar plus detail), and .detailOnly (detail fills the screen).
You can also set the navigation split view style to .balanced (resizes the detail when showing sidebars) or .prominentDetail (detail stays full-width and sidebars overlay):
.navigationSplitViewStyle(.prominentDetail)
Common Pitfalls and How to Avoid Them
I've run into all of these at some point, so hopefully you can skip the headaches.
Never mutate the path during a view update. Modifying the navigation path inside body or a computed property will cause SwiftUI to complain or silently break. Always trigger path changes from button actions, onAppear, or task modifiers.
Don't mix NavigationLink with programmatic navigation on the same stack. They fight over path ownership, and you'll get weird behavior. With the router pattern, use programmatic navigation exclusively — views call router.push() instead of using NavigationLink with a value.
Keep state out of destination views. NavigationStack creates new view instances on push. If you store important state inside a destination view, it'll be lost when the user navigates away and comes back. Keep shared state in the router, a model, or the environment.
Reset the path before setting a new deep link stack. If you don't call popToRoot or replaceStack and instead just append routes, the user might end up with an unexpected stack. When handling deep links, always replace the entire stack.
Use NavigationSplitView as the root for universal apps. On iPhone, it collapses to a single-column NavigationStack automatically. On iPad and Mac, it gives you the multi-column layout users expect. You don't need to conditionally switch between the two — just let SwiftUI do its thing.
Frequently Asked Questions
What is the difference between NavigationStack and NavigationSplitView?
NavigationStack provides single-column, push-based navigation — the standard drill-down pattern you see on iPhone. NavigationSplitView provides multi-column navigation with a sidebar, content, and detail area — the layout users expect on iPad and Mac. On compact size classes like iPhone, NavigationSplitView automatically collapses to behave like a NavigationStack.
How do I handle deep links with NavigationStack in SwiftUI?
Parse the incoming URL into an array of your route values, then set the navigation path to that array. Use the onOpenURL modifier at the root of your app to intercept URLs. Because the navigation state is just a plain array, you can construct any stack depth from a single URL — including multi-level deep links that push several screens at once.
Can I use NavigationStack inside NavigationSplitView?
Yes, and it's actually the recommended approach. Embedding a NavigationStack in the detail column of a NavigationSplitView gives you sidebar-based top-level navigation with push-based drill-down within each section. The sidebar handles high-level selection while the embedded NavigationStack handles deeper navigation.
How do I save and restore navigation state across app launches?
Make your route enum conform to Codable, then use SceneStorage to persist the encoded navigation path. On launch, decode the saved data and replace the router's path. SwiftUI will automatically reconstruct the entire navigation stack, returning the user to exactly where they left off.
Should I use NavigationPath or a typed array for my navigation path?
Start with a typed array of your route enum. It's simpler, provides better type safety, and makes debugging easier because you can inspect the exact route at each position. Only use NavigationPath when you genuinely need to push values of different unrelated types onto the same stack — which is rarer than you might think.