NavigationView to NavigationStack Migration Playbook (SwiftUI)

Since iOS 16 and macOS 13, SwiftUI has two newer containers that replace NavigationView: NavigationStack and NavigationSplitView. In the new model, navigation is driven by data. This playbook converts an existing app step by step: the container, the links, programmatic navigation, and the deprecated isActive and tag:selection: patterns. Each step has before/after code that compiles against the current SDK.

Quick answer: Replace a single-column NavigationView with NavigationStack and a sidebar-plus-detail NavigationView with NavigationSplitView. Turn links into NavigationLink(value:) and register one navigationDestination(for:) per data type inside the stack, outside any List or lazy stack. Replace isActive: and tag:selection: links with a path binding (NavigationStack(path:)), navigationDestination(isPresented:), or List(selection:) in a split view.

Difficulty: Intermediate

You'll understand this better if you know: SwiftUI views and modifiers, state management with @State and @Binding, and the Hashable protocol. The basics of both containers are covered in SwiftUI Navigation.

1. What Changes and Why

With NavigationView, each NavigationLink held its destination view and, for programmatic control, its own isActive or selection binding. A deep link three screens down needed three coordinated bindings. Popping to the root meant resetting all of them.

The new containers separate what is on the stack from how it is drawn:

  • NavigationStack keeps a path, a list of Hashable values. Pushing a screen means appending a value. Popping means removing one.
  • navigationDestination(for:) maps a value type to a view once per stack. It replaces the destination views that were spread across every link.
  • NavigationSplitView handles two- and three-column layouts. In a NavigationView, those depended on how many child views you passed in and on navigationViewStyle.

How hard the deprecations push you depends on the API, which is why some old code compiles cleanly and some does not:

APIStatus in the current SDKCompiler warning?
NavigationViewDeprecated with no version ("use NavigationStack or NavigationSplitView instead")No
.navigationViewStyle(_:)Deprecated with no versionNo
NavigationLink(destination:isActive:label:) and other isActive initializersDeprecated in iOS 16, macOS 13, tvOS 16, watchOS 9Yes, once your deployment target reaches those versions
NavigationLink(tag:selection:…) initializersDeprecated in iOS 16, macOS 13, tvOS 16, watchOS 9Yes
NavigationLink(destination: View, label:) (view value, not closure)Deprecated with no version ("Pass a closure as the destination")No
NavigationLink { Destination() } label: { … }CurrentNo

Note: "Deprecated with no version" means the SDK marks the API with deprecated: 100000.0. The documentation lists it as deprecated, but the compiler does not warn yet. A build without warnings does not mean the migration is finished.

2. Prerequisites and Versions

APIMinimum OS
NavigationStack, NavigationSplitView, NavigationPathiOS 16, macOS 13, tvOS 16, watchOS 9
NavigationLink(value:), navigationDestination(for:), navigationDestination(isPresented:)iOS 16, macOS 13, tvOS 16, watchOS 9
navigationDestination(item:)iOS 17, macOS 14, tvOS 17, watchOS 10
@Observable router (Observation framework)iOS 17, macOS 14, tvOS 17, watchOS 10

If your deployment target is iOS 16 or later, you can migrate directly. If you still support iOS 15, see section 11. All examples below use this model:

Swift
import SwiftUI

struct Recipe: Identifiable, Hashable, Codable {
    let id: Int
    var name: String
}

struct RecipeDetail: View {
    let recipe: Recipe
    var body: some View { Text(recipe.name).navigationTitle(recipe.name) }
}

struct SettingsView: View {
    var body: some View { Text("Settings").navigationTitle("Settings") }
}

3. Step-by-Step Migration Order

  1. Inventory. Find every old API: git grep -n -E 'NavigationView|navigationViewStyle|isActive:|tag:|navigationBarTitle\(|navigationBarItems|presentationMode'. The tag: pattern catches the deprecated tag:selection: links.
  2. Classify each NavigationView. A single column of pushed screens becomes NavigationStack. A sidebar with a detail area (two or three child views, or .navigationViewStyle(.columns)) becomes NavigationSplitView.
  3. Swap the container (section 4). Links that use a destination closure keep working inside NavigationStack, so the app keeps working after this step.
  4. Convert links to value-based ones where you need programmatic control, deep links, or state restoration, and add navigationDestination(for:) (section 5).
  5. Replace isActive and tag:selection: with navigationDestination(isPresented:), a path, or split-view selection (sections 6–8).
  6. Put routes and path ownership in one place. Use one Route enum per stack and one owner of the path (section 7).
  7. Clean up related APIs: navigationBarTitle → navigationTitle, navigationBarItems → toolbar, presentationMode → dismiss.
  8. Verify with the checklist in section 13, watching the Xcode console for SwiftUI runtime warnings (section 10).

4. Swap the Container

Before: a typical iOS 13–15 screen with a hidden isActive link for a toolbar button:

Swift
struct RecipeListView: View {
    let recipes: [Recipe]
    @State private var showSettings = false

    var body: some View {
        NavigationView {
            List(recipes) { recipe in
                NavigationLink(destination: RecipeDetail(recipe: recipe)) {
                    Text(recipe.name)
                }
            }
            .navigationTitle("Recipes")
            .background(
                NavigationLink(destination: SettingsView(), isActive: $showSettings) {
                    EmptyView()
                }
            )
            .toolbar {
                Button("Settings") { showSettings = true }
            }
        }
        .navigationViewStyle(.stack)
    }
}

With an iOS 17 deployment target, this compiles with one warning: 'init(destination:isActive:label:)' was deprecated in iOS 16.0: use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView. NavigationView and navigationViewStyle produce no warning.

After:

Swift
struct RecipeListView: View {
    let recipes: [Recipe]
    @State private var showSettings = false

    var body: some View {
        NavigationStack {
            List(recipes) { recipe in
                NavigationLink(recipe.name, value: recipe)
            }
            .navigationTitle("Recipes")
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetail(recipe: recipe)
            }
            .navigationDestination(isPresented: $showSettings) {
                SettingsView()
            }
            .toolbar {
                Button("Settings") { showSettings = true }
            }
        }
    }
}

What changed: the container, the .navigationViewStyle(.stack) modifier is gone because a stack is always a stack, the row links carry a value instead of a view, and the hidden EmptyView link became a navigationDestination(isPresented:) modifier.

5. Value-Based Links and navigationDestination(for:)

NavigationLink(value:) only appends a value. The stack looks for a matching navigationDestination(for:) to decide what to show. Follow these rules:

  • Attach destinations inside the stack, and outside lazy containers. Put the modifier on the List or ScrollView, not on a row inside it. Lazy containers create rows only when they are on screen, so a destination attached to a row may not exist when the stack needs it.
  • Register one destination per type per stack. If two views on the same stack register Recipe.self, only the one closest to the root is used.
  • Give each kind of screen its own type. If several screens share Int or String, they all resolve to the same destination. Use dedicated types or an enum (section 7).
  • Destination-closure links still work. NavigationLink { RecipeDetail(recipe: r) } label: { … } is valid inside a NavigationStack. You do not have to convert links that never need programmatic control. Value-based links are what make the path complete, though: a screen pushed by a destination-closure link has no value in your path.
Swift
struct RecipeBrowser: View {
    let recipes: [Recipe]

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVStack(alignment: .leading) {
                    ForEach(recipes) { recipe in
                        NavigationLink(value: recipe) {
                            Label(recipe.name, systemImage: "fork.knife")
                        }
                    }
                }
            }
            // Outside the LazyVStack, inside the NavigationStack
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetail(recipe: recipe)
            }
            .navigationTitle("Browse")
        }
    }
}

6. Replacing isActive

An isActive link was a Bool that pushed one screen. There are two direct replacements.

One screen, driven by a Bool: use navigationDestination(isPresented:), as shown in section 4. Setting the binding to false pops the screen, and the back button sets it to false for you.

Any screen, from anywhere: append to the path. A login flow that used to chain three isActive flags becomes an array:

Swift
enum OnboardingStep: Hashable {
    case permissions, account, done
}

struct OnboardingView: View {
    @State private var path: [OnboardingStep] = []

    var body: some View {
        NavigationStack(path: $path) {
            Button("Start") { path.append(.permissions) }
                .navigationDestination(for: OnboardingStep.self) { step in
                    switch step {
                    case .permissions:
                        Button("Allow") { path.append(.account) }
                    case .account:
                        Button("Create account") { path.append(.done) }
                    case .done:
                        Button("Finish") { path.removeAll() }   // pop to root
                    }
                }
        }
    }
}

7. Programmatic Navigation with a Path

Typed path or NavigationPath?

[Route] (typed array)NavigationPath
Element typesOne Hashable type, usually an enumAny mix of Hashable types
Inspect or rewrite the stackYes, it is an ordinary arrayNo. It exposes only count, isEmpty, append(_:), removeLast(_:) and codable
State restorationMake Route Codablepath.codable (non-nil only if every element is Codable)
Best forApp-owned flows, deep links, testsStacks shared by modules that don't know each other's types

Most apps should start with a typed enum:

Swift
enum Route: Hashable {
    case recipe(Recipe)
    case settings
}

struct AppRootView: View {
    let recipes: [Recipe]
    @State private var path: [Route] = []

    var body: some View {
        NavigationStack(path: $path) {
            List(recipes) { recipe in
                NavigationLink(recipe.name, value: Route.recipe(recipe))
            }
            .navigationTitle("Recipes")
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .recipe(let recipe):
                    RecipeDetail(recipe: recipe)
                case .settings:
                    SettingsView()
                }
            }
            .toolbar {
                Button("Settings") { path.append(.settings) }
            }
        }
        .onOpenURL { url in
            // myapp://recipe/2 opens that recipe directly on top of the root
            if let id = Int(url.lastPathComponent),
               let recipe = recipes.first(where: { $0.id == id }) {
                path = [.recipe(recipe)]
            }
        }
    }
}

Pop one screen with path.removeLast() (check isEmpty first, because removing from an empty array traps). Pop to the root with path.removeAll(). A deep link is a single assignment.

Sharing the path with a router (iOS 17+)

Child views often need to navigate without receiving the binding through every initializer. An @Observable router in the environment does that:

Swift
import SwiftUI
import Observation

enum Route: Hashable {
    case recipe(Recipe)
    case settings
}

@MainActor
@Observable
final class Router {
    var path: [Route] = []

    func show(_ recipe: Recipe) { path.append(.recipe(recipe)) }
    func showSettings() { path.append(.settings) }
    func popToRoot() { path.removeAll() }
}

struct RootView: View {
    let recipes: [Recipe]
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            RecipeList(recipes: recipes)
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .recipe(let recipe): RecipeDetail(recipe: recipe)
                    case .settings: SettingsView()
                    }
                }
        }
        .environment(router)
    }
}

struct RecipeList: View {
    let recipes: [Recipe]
    @Environment(Router.self) private var router

    var body: some View {
        List(recipes) { recipe in
            Button(recipe.name) { router.show(recipe) }
        }
        .navigationTitle("Recipes")
        .toolbar {
            Button("Settings") { router.showSettings() }
        }
    }
}

On iOS 16, use an ObservableObject with @Published var path instead, injected with .environmentObject.

State restoration with NavigationPath (iOS 17+)

Swift
struct RestorableStackView: View {
    @State private var path = NavigationPath()
    @SceneStorage("recipes.navigation") private var savedPath: Data?

    var body: some View {
        NavigationStack(path: $path) {
            Button("Open Soup") { path.append(Recipe(id: 1, name: "Soup")) }
                .navigationDestination(for: Recipe.self) { RecipeDetail(recipe: $0) }
        }
        .task {
            guard let savedPath,
                  let representation = try? JSONDecoder().decode(
                      NavigationPath.CodableRepresentation.self, from: savedPath)
            else { return }
            path = NavigationPath(representation)
        }
        .onChange(of: path) { _, newPath in
            guard let representation = newPath.codable else { return }
            savedPath = try? JSONEncoder().encode(representation)
        }
    }
}

8. Replacing tag:selection:

NavigationLink(tag:selection:) was mostly used for list-detail layouts and for selecting a row from code. In a split view, the list's own selection replaces it.

Before:

Swift
struct TagSelectionView: View {
    let recipes: [Recipe]
    @State private var selectedID: Int?

    var body: some View {
        NavigationView {
            List(recipes) { recipe in
                NavigationLink(recipe.name, tag: recipe.id, selection: $selectedID) {
                    RecipeDetail(recipe: recipe)
                }
            }
            Text("Select a recipe")
        }
    }
}

Warning: 'init(_:tag:selection:destination:)' was deprecated in iOS 16.0: use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView.

After:

Swift
struct RecipeSplitView: View {
    let recipes: [Recipe]
    @State private var selectedID: Recipe.ID?

    var body: some View {
        NavigationSplitView {
            List(recipes, selection: $selectedID) { recipe in
                Text(recipe.name)
            }
            .navigationTitle("Recipes")
        } detail: {
            if let recipe = recipes.first(where: { $0.id == selectedID }) {
                RecipeDetail(recipe: recipe)
            } else {
                Text("Select a recipe")
            }
        }
    }
}

Setting selectedID in code selects the row and shows its detail. On iPhone, the split view collapses into a single stack automatically. For a three-column layout, use NavigationSplitView { } content: { } detail: { }. If the detail column needs its own pushes, put a NavigationStack inside the detail closure.

Inside a plain stack on iOS 17+, an optional binding gives you the same "push when non-nil" behavior:

Swift
struct SearchView: View {
    let recipes: [Recipe]
    @State private var selected: Recipe?

    var body: some View {
        NavigationStack {
            List(recipes) { recipe in
                Button(recipe.name) { selected = recipe }
            }
            .navigationDestination(item: $selected) { recipe in
                RecipeDetail(recipe: recipe)
            }
        }
    }
}

9. Mapping Table: Old API to New API

Old (NavigationView era)New
NavigationView { content } (single column)NavigationStack { content }
NavigationView { sidebar; detail }NavigationSplitView { sidebar } detail: { detail }
.navigationViewStyle(.stack)Remove it and use NavigationStack
.navigationViewStyle(.columns)NavigationSplitView
NavigationLink(destination: V()) { label }NavigationLink(value: x) { label } + .navigationDestination(for: X.self), or NavigationLink { V() } label: { label }
NavigationLink(destination:isActive:).navigationDestination(isPresented:) or path.append(_:)
NavigationLink(tag:selection:)List(selection:) in NavigationSplitView, .navigationDestination(item:) (iOS 17), or a path
Hidden EmptyView links for programmatic pushespath.append(route)
Resetting a chain of isActive flags to pop to rootpath.removeAll() or path = NavigationPath()
.navigationBarTitle("x").navigationTitle("x")
.navigationBarItems(trailing:).toolbar { ToolbarItem(placement: .topBarTrailing) { … } }
@Environment(\.presentationMode)@Environment(\.dismiss) (iOS 15+)

10. Common Errors and Warnings

Compile-time deprecation warnings

  • 'init(destination:isActive:label:)' was deprecated in iOS 16.0: use NavigationLink(value:label:), or navigationDestination(isPresented:destination:), inside a NavigationStack or NavigationSplitView. Fix: section 6.
  • 'init(_:tag:selection:destination:)' was deprecated in iOS 16.0: … (same suggestion). Fix: section 8.

Runtime messages in the Xcode console

These come from SwiftUI at run time, not from the compiler. The link or destination silently does nothing and SwiftUI logs a message. The wording below is taken from the SwiftUI framework in the current OS release and can differ slightly between OS versions.

MessageCause and fix
A NavigationLink is presenting a value of type “Recipe” but there is no matching navigationDestination declaration visible from the location of the link. The link cannot be activated.This stack has no navigationDestination(for: Recipe.self). Add one inside the NavigationStack, on the root content.
The `navigationDestination` modifier only works inside a `NavigationStack` or `NavigationSplitView`. There's a misplaced `navigationDestination(for:destination:)` modifier for type `Recipe`. …The modifier is attached to the NavigationStack itself or to a view outside it. Move it onto a view inside the stack's closure.
Do not put a navigation destination modifier inside a "lazy” container, like `List` or `LazyVStack`. …The modifier is on a row. Attach it to the List or ScrollView itself.
A navigationDestination for “Recipe” was declared earlier on the stack. Only the destination declared closest to the root view of the stack will be used.Two views on the same stack register the same type. Keep one registration per type, near the root.
NavigationLink presenting a value must appear inside a NavigationStack, NavigationSplitView, or List with selection. Link will be disabled.A NavigationLink(value:) is still inside a NavigationView. Finish swapping the container.
Only root-level navigation destinations are effective for a navigation stack with a homogeneous path.With a typed path such as [Route], a destination was registered on a pushed screen. Register it on the stack's root content.

Problems with no message

  • Nested stacks: a pushed destination that wraps itself in another NavigationStack can produce a second navigation bar and confusing back behavior. Only the root of a stack, and each tab or split-view column, should own one.
  • Mixed value types on a typed path: a stack bound to [Route] can only hold Route values. Wrap links as .recipe(recipe), or switch to NavigationPath if the stack really needs mixed types.
  • The detail screen shows stale data: the value you pushed is a copy taken at push time. Push an identifier (.recipe(id:)) and look the model up in your store when the destination is built.

11. Supporting iOS 15 and Earlier

If your deployment target is below iOS 16, wrap the container choice in one place. Then most call sites can move to the new structure now and drop the wrapper when you raise the target. This version is iOS-only because .stack does not exist on macOS:

Swift
import SwiftUI

struct CompatNavigation<Content: View>: View {
    @ViewBuilder var content: () -> Content

    var body: some View {
        if #available(iOS 16, *) {
            NavigationStack { content() }
        } else {
            NavigationView { content() }
                .navigationViewStyle(.stack)
        }
    }
}

Value-based links, navigationDestination and paths require iOS 16, so the iOS 15 branch can only use destination-closure links. In practice, a full migration usually waits until iOS 16 is the minimum.

12. Pitfalls

  • Converting the container but not the links, then adding a path. Screens pushed by destination-closure links are not represented in the path. Convert every link in a flow you control from code.
  • One giant Route enum for the whole app. Use one route type per stack (per tab or per flow) so each stack's destinations stay local.
  • Putting non-trivial state in route values. Routes are hashed and compared. Keep them small and immutable: IDs and simple parameters.
  • Trusting a clean build. NavigationView and navigationViewStyle produce no warnings. Run the inventory search from section 3.
  • Calling removeLast() on an empty path. It traps, so guard with isEmpty.
  • Testing only on iPhone. NavigationSplitView collapses on compact width and shows columns on iPad and Mac. Check selection and back behavior in both layouts.

13. Verification Checklist

  • The inventory search from section 3 returns no NavigationView, navigationViewStyle, isActive: or tag:/selection: link usage, except inside an intentional compatibility wrapper.
  • The build has no "deprecated in iOS 16.0" navigation warnings.
  • Walking every flow produces none of the runtime messages in section 10 in the Xcode console.
  • Deep links and pop-to-root work by changing the path only, without flags.
  • Back buttons, swipe-back, and the system's long-press back menu show the expected titles.
  • Split views behave correctly on iPhone (collapsed), iPad (portrait and landscape) and Mac, if you support them.
  • State restoration, if you use it, brings back the same stack after the app is relaunched.

14. Key Points

  • Use NavigationStack for single-column flows and NavigationSplitView for sidebar/detail layouts (iOS 16, macOS 13, tvOS 16, watchOS 9).
  • The new model is data-driven: links append values, and navigationDestination(for:) maps value types to views once per stack.
  • isActive and tag:selection: links are deprecated with warnings. NavigationView is deprecated without a warning.
  • A typed path ([Route]) makes deep links, pop-to-root and tests simple. NavigationPath handles mixed types and has a Codable representation.
  • Attach destinations inside the stack but outside lazy containers, and register each type once.
  • SwiftUI reports navigation mistakes as runtime console messages, so check the console while you test.

15. Final Summary

Migrating from NavigationView is mostly a change in structure. Choose a stack or a split view, move destinations from individual links into navigationDestination(for:), and replace the per-link isActive and selection bindings with one path or one list selection. Once the path is an ordinary array of routes, deep links, pop-to-root and state restoration each take one or two lines.

Work through the steps in order: swap the container first (the app keeps working), then convert links where you need control, then remove the deprecated initializers. Finally, verify with the console open, because most navigation mistakes show up only at run time. Apple's Migrating to new navigation types guide and the NavigationStack reference cover the same ground from Apple's side.