If you’ve worked on any non-trivial SwiftUI app, you’ve felt it - navigation logic scattered across views, random NavigationLinks fighting each other, and deep linking bolted on as an afterthought. It works until it doesn’t, and then you’re hunting through ten files to figure out why tapping “Settings” opened the wrong screen.

NavigationStack (iOS 16+) gives us the tools to fix this. But the real unlock isn’t the API itself - it’s the coordinator pattern that it enables.

Let’s build navigation you can actually reason about.

To better understand the concepts behind each pattern, you can play around with the companion app - each tab maps to a pattern covered here.

Think of It Like a Museum

Working on an app with multiple screens, features, and points of interest - especially on a bigger team - can feel like walking into an enormous museum you’ve never been to. You’re checking every room, each room has doors leading to other rooms, and then someone tells you that you need to build a new pathway between two exhibits on opposite ends of the building. It gets messy fast.

That’s what navigation without structure feels like. Every view has its own NavigationLinks pointing in different directions. You lose track of what leads where, and adding a new route means touching five files you barely remember.

Now imagine you have a tour guide. The guide holds the full itinerary, knows exactly which rooms have been visited, and can reroute the group at any time - skip ahead, go back, or take a detour through a special exhibit. You don’t figure out the path yourself. You just ask the guide.

That’s the coordinator pattern. Instead of each view deciding where to go next, one central object holds the entire navigation path and controls all transitions.

Your First Coordinator

The core idea: replace scattered NavigationLink destinations with a single coordinator that owns the path.

Why a Coordinator?

Three reasons this matters in production apps:

  • Testability - you can unit-test navigation logic without rendering a single view. Push a route, assert the path changed. No UI tests needed for flow logic.

  • Single source of truth - every screen change goes through one place. When something navigates wrong, you know exactly where to look.

  • Deep linking for free - since the path is just data, you can construct it from a URL, a push notification, or a widget tap.

The Implementation

@Observable
class AppCoordinator {
    // The itinerary - every screen the user has visited
    var path: [Route] = []

    func navigate(to route: Route) {
        path.append(route)
    }

    // Take everyone back to the lobby
    func popToRoot() {
        path.removeAll()
    }
}

That’s it for the coordinator itself. A path array and methods to modify it.

Type-Safe Routes with Enums

You could use NavigationPath (Apple’s type-erased version), but you’d lose compile-time safety. Instead, define your routes as an enum:

enum Route: Hashable {
    case detail(Item)
    case settings
    case profile(User)
    case nested(NestedRoute)
}

Every possible destination is explicit. If you add a new screen, the compiler forces you to handle it everywhere. No stringly-typed routes, no runtime crashes from unexpected types.

Wiring It to Views

The coordinator lives at the root and provides navigation destinations for the entire stack:

struct ContentView: View {
    @State private var coordinator = AppCoordinator()

    var body: some View {
        NavigationStack(path: $coordinator.path) {
            RootView()
                .navigationDestination(for: Route.self) { route in
                    destinationView(for: route)
                }
        }
        .environment(coordinator)
    }

    @ViewBuilder
    private func destinationView(for route: Route) -> some View {
        switch route {
        case .detail(let item):
            DetailView(item: item)
        case .settings:
            SettingsView()
        case .profile(let user):
            ProfileView(user: user)
        case .nested(let nestedRoute):
            NestedView(route: nestedRoute)
        }
    }
}

Any child view can now trigger navigation by calling coordinator.navigate(to:) through the environment - without knowing anything about the destination view. The view says where to go, the coordinator decides how.

Deep Linking Becomes Trivial

Here’s where the coordinator pattern really pays off. Since navigation state is just an array of routes, constructing it from a URL is a one-liner:

extension AppCoordinator {
    func handle(url: URL) {
        guard url.scheme == "myapp" else { return }

        // Build the itinerary from the URL
        switch url.host {
        case "item":
            if let id = url.pathComponents.last,
               let item = fetchItem(id: id) {
                path = [.detail(item)]
            }
        case "settings":
            path = [.settings]
        default:
            break
        }
    }

    private func fetchItem(id: String) -> Item? {
        // Look up from your data store, Core Data, network cache, etc.
        dataStore.items.first { $0.id == id }
    }
}

Notice how we’re replacing the entire path, not appending. The user taps a deep link, and the coordinator sets up the exact itinerary - no matter where they were before. The tour guide says “forget the current tour, we’re going here now.”

This same approach works for push notifications, widgets, Spotlight results, or any external entry point.

Onboarding: A Self-Contained Tour

For multi-step flows like onboarding, create a dedicated coordinator. Think of it as a guided tour within the museum - a fixed sequence of rooms with a clear endpoint:

@Observable
class OnboardingCoordinator {
    var path: [Step] = []
    var isCompleted = false

    enum Step: Hashable {
        case welcome
        case permissions
        case preferences
    }

    // Move to the next room in the tour
    func advance(from current: Step) {
        switch current {
        case .welcome:
            path.append(.permissions)
        case .permissions:
            path.append(.preferences)
        case .preferences:
            isCompleted = true
        }
    }
}

Since WelcomeView is the root of the NavigationStack, it doesn’t appear in the path array - it’s always there. The advance(from:) method takes the current step so each view just says “I’m done, what’s next?” without needing to know the order.

The flow owns its own path. The parent view just watches for isCompleted and dismisses:

struct OnboardingFlow: View {
    @State private var coordinator = OnboardingCoordinator()
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack(path: $coordinator.path) {
            WelcomeView()
                .navigationDestination(for: OnboardingCoordinator.Step.self) { step in
                    destinationView(for: step)
                }
        }
        .environment(coordinator)
        .onChange(of: coordinator.isCompleted) { _, completed in
            if completed { dismiss() }
        }
    }

    @ViewBuilder
    private func destinationView(for step: OnboardingCoordinator.Step) -> some View {
        switch step {
        case .welcome:
            WelcomeView()
        case .permissions:
            PermissionsView()
        case .preferences:
            PreferencesView()
        }
    }
}

Each step calls coordinator.advance(from:) with its own step - it doesn’t know or care what comes next. If you later add a “terms of service” step between permissions and preferences, you change one method. No views need to update.

Auth Flows: Two Tours, One Guide

Authentication is a common case where you need completely separate navigation stacks - one for logged-out users, one for logged-in:

@Observable
class AuthCoordinator {
    var isAuthenticated = false
    var mainPath: [MainRoute] = []
    var authPath: [AuthRoute] = []

    // End the tour, send everyone home
    func logout() {
        isAuthenticated = false
        mainPath.removeAll()
        authPath.removeAll()
    }
}

The root view switches between stacks based on auth state:

struct RootView: View {
    @State private var coordinator = AuthCoordinator()

    var body: some View {
        Group {
            if coordinator.isAuthenticated {
                NavigationStack(path: $coordinator.mainPath) {
                    MainView()
                        .navigationDestination(for: MainRoute.self) { route in
                            // Main app screens
                        }
                }
            } else {
                NavigationStack(path: $coordinator.authPath) {
                    LoginView()
                        .navigationDestination(for: AuthRoute.self) { route in
                            // Login, signup, forgot password
                        }
                }
            }
        }
        .environment(coordinator)
    }
}

When isAuthenticated flips, SwiftUI swaps the entire stack. No animation hacks, no manual cleanup. The coordinator clears both paths on logout, so the user always starts fresh.

Keep Your Routes Clean

A few patterns that will save you debugging time:

  • Don’t mix NavigationLink with programmatic navigation on the same stack - they fight over path ownership. Pick one approach and stick with it. The coordinator pattern means you always use programmatic navigation.

  • Never mutate the path during a view update - SwiftUI will complain (or silently break). Trigger path changes from button actions, onAppear, or task - not from computed properties or body.

  • Reference types in routes need proper Hashable conformance - if your Item is a class, make sure hash(into:) and == are based on a stable identifier, not object identity. Otherwise the same item can appear as “different” to NavigationStack.

  • NavigationStack creates new view instances on push - don’t store important state inside destination views. Keep it in the coordinator or a shared model.

Key Takeaways

  1. One coordinator, one path - navigation state lives in a single @Observable object. When something navigates wrong, you check one file, not ten
  2. Enums over type-erased paths - let the compiler catch missing routes at build time. Runtime crashes from bad routes are not a fun way to spend a Friday
  3. Deep linking is just data - construct the path array from a URL and NavigationStack does the rest. No special infrastructure needed
  4. Isolate flows - onboarding, auth, and feature modules each get their own coordinator. One team’s changes won’t break another team’s navigation
  5. Views trigger, coordinators decide - views say “go to settings”, the coordinator manages how. Your views stay small and dumb, which is exactly what you want
  6. Test without UI - push routes, assert paths, verify flows. All in unit tests, all fast, no simulators spinning up at 2am in CI