SwiftUI Navigation with Coordinators: Scaling NavigationStack Without Losing Your Mind

Introduction

NavigationStack was released in iOS 16 and I was originally excited for it. For years before that, SwiftUI navigation felt like a collection of half measures: NavigationView, NavigationLink scattered through views, boolean bindings to trigger pushes, and a long list of weird hacks just to present screens conditionally. When things became truly complex, we inevitably fell back to UIHostingController, dragging UIKit back into places where SwiftUI was supposed to shine.

Before SwiftUI, I was always a fan of the router / coordinator pattern in UIKit — a dedicated component responsible for navigation and flow control. Views didn’t decide where to go. View models didn’t push controllers. Navigation logic lived in one place, was explicit, and was testable.

With NavigationStack, it finally felt like SwiftUI might support that same level of architectural discipline.

As I’ve been working on my personal project Giftr, I’ve also noticed something else: AI assisted code generation consistently gets two things wrong unless you aggressively enforce constraints — threading and navigation. Threading issues usually fail loudly. Navigation issues don’t. They quietly rot your architecture over time.

This post is about how I structure SwiftUI navigation today using NavigationStack, coordinators, and intent based APIs while avoiding god coordinators, EnvironmentObject abuse, and view driven routing.


The Core Principle: Navigation Is Not a View Concern

The biggest mistake I see (especially in AI generated SwiftUI code) is treating navigation as a UI concern.

1
2
3
Button("Show gifts") {
    path.append(.giftList(profile))
}

This works — until it doesn’t.

  • Views now know where the app goes next
  • Logic is duplicated across screens
  • Conditional flows become fragile
  • Navigation becomes impossible to reason about globally

Navigation is an application level concern. Views express intent. Coordinators decide what happens next.


Architecture Overview

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
┌─────────────────────────┐
│      NavigationStack    │  ← owns navigation state
│   (created once per     │
│        flow)            │
└───────────┬─────────────┘
┌───────────▼─────────────┐
│      Flow Wrapper       │  ← sets destinations
│  (e.g. ProfileFlowView) │
└───────────┬─────────────┘
┌───────────▼─────────────┐
│        Coordinator      │  ← decides navigation
│   (intent → route)      │
└───────────┬─────────────┘
┌───────────▼─────────────┐
│       View Factory      │  ← wires views + VMs
└───────────┬─────────────┘
┌───────────▼─────────────┐
│      ViewModel          │  ← exposes intent only
└───────────┬─────────────┘
┌───────────▼─────────────┐
│          View           │  ← buttons = intent
└─────────────────────────┘

This separation is what keeps navigation scalable.


Where the NavigationStack Lives

Rule: A NavigationStack should be created once per flow, not per screen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct ProfileFlowView: View {
    @StateObject private var coordinator: ProfileCoordinator

    var body: some View {
        NavigationStack(path: $coordinator.navigation.path) {
            coordinator.startView
                .navigationDestination(
                    for: ProfileRoute.self,
                    destination: coordinator.destination
                )
        }
    }
}
  • The stack is not owned by individual views
  • Destinations are registered centrally
  • The path is controlled by the coordinator

Modules should not navigate directly to each other.

Instead, they communicate via routes owned by a coordinator:

1
2
3
4
5
enum ProfileRoute: Hashable {
    case profileDetail(Profile)
    case giftList(Profile)
    case giftDetail(Gift, Profile)
}

The coordinator decides how module boundaries are crossed:

1
2
3
func showGifts(for profile: Profile) {
    navigation.push(.giftList(profile))
}

The Profile module doesn’t know how gifts are shown — only that the user expressed intent.


Avoid a single app wide coordinator.

Instead, compose coordinators:

1
2
3
4
AppCoordinator
 ├─ ProfileCoordinator
 │    └─ GiftCoordinator
 └─ SettingsCoordinator

Child coordinators can signal completion upward:

1
2
3
protocol FlowCoordinator {
    func finish()
}
1
2
3
4
5
6
7
final class GiftCoordinator {
    let onFinish: () -> Void

    func didFinishViewingGift() {
        onFinish()
    }
}

This keeps flows isolated and composable.


Avoiding EnvironmentObject

@EnvironmentObject hides dependencies and makes flows implicit.

Instead, inject dependencies explicitly:

1
ProfileDetailView(viewModel: viewModel)
1
2
3
4
5
6
7
final class ProfileDetailViewModel {
    let onShowGifts: () -> Void

    init(onShowGifts: @escaping () -> Void) {
        self.onShowGifts = onShowGifts
    }
}

Explicit dependencies scale. Magic doesn’t.


Intent Based Navigation (Not “Go To Screen X”)

Views and view models should not say where to go.

They should say what happened.

1
2
3
Button("Show gifts") {
    viewModel.didTapShowGifts()
}
1
2
3
func didTapShowGifts() {
    onShowGifts()
}

The coordinator decides what that intent means in context.

This solves flows like:

  • Profile created → push detail
  • Profile selected → pop to detail

Same intent. Different outcome.


Buttons Can Have Intent Without Coupling

Button titles can still be explicit:

1
Button("Show gifts") { viewModel.didTapShowGifts() }

The label is UI. The effect is intent.

This is not a contradiction — it’s separation of concerns.


Using Protocols for Coordinators

Expose only what a screen needs:

1
2
3
protocol ProfileCoordinating {
    func showGifts(for profile: Profile)
}

This allows coordinators to be passed around safely, mocked in tests, and swapped between flows.


Ending a Flow Cleanly

Flows should end explicitly:

1
2
3
4
func didFinishProfileCreation() {
    navigation.popToRoot()
    onFlowFinished()
}

No guessing. No implicit dismissal.


Injecting a View Factory

Coordinators should not create views directly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class ProfileViewFactory {
    func makeProfileDetail(
        profile: Profile,
        coordinator: ProfileCoordinator
    ) -> some View {
        let viewModel = ProfileDetailViewModel(
            onShowGifts: {
                coordinator.showGifts(for: profile)
            }
        )

        return ProfileDetailView(viewModel: viewModel)
    }
}

This keeps coordinators UI-agnostic.


Inverting ViewModel → Coordinator Dependency

ViewModels depend on closures, not coordinators:

1
2
3
final class ProfileDetailViewModel {
    let onShowGifts: () -> Void
}

The app composition layer wires everything together.


Inject Coordinator Into ViewModel (Not the View)

Views render. ViewModels coordinate intent.

1
ProfileDetailView(viewModel: viewModel)

Not:

1
ProfileDetailView(coordinator: coordinator) // ❌

This keeps SwiftUI views declarative and dumb — exactly what they should be.


Final Rules of Thumb

  • NavigationStack is infrastructure, not logic
  • Views express intent, never routes
  • Coordinators decide, factories construct
  • Flows end explicitly
  • Avoid global state and hidden dependencies

SwiftUI navigation can scale — but only if you treat it like architecture, not syntax.