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.
| |
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
| |
This separation is what keeps navigation scalable.
Where the NavigationStack Lives
Rule: A NavigationStack should be created once per flow, not per screen.
| |
- The stack is not owned by individual views
- Destinations are registered centrally
- The path is controlled by the coordinator
Navigation Between Modules
Modules should not navigate directly to each other.
Instead, they communicate via routes owned by a coordinator:
| |
The coordinator decides how module boundaries are crossed:
| |
The Profile module doesn’t know how gifts are shown — only that the user expressed intent.
Navigation Between Multiple Coordinators
Avoid a single app wide coordinator.
Instead, compose coordinators:
| |
Child coordinators can signal completion upward:
| |
| |
This keeps flows isolated and composable.
Avoiding EnvironmentObject
@EnvironmentObject hides dependencies and makes flows implicit.
Instead, inject dependencies explicitly:
| |
| |
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.
| |
| |
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:
| |
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:
| |
This allows coordinators to be passed around safely, mocked in tests, and swapped between flows.
Ending a Flow Cleanly
Flows should end explicitly:
| |
No guessing. No implicit dismissal.
Injecting a View Factory
Coordinators should not create views directly.
| |
This keeps coordinators UI-agnostic.
Inverting ViewModel → Coordinator Dependency
ViewModels depend on closures, not coordinators:
| |
The app composition layer wires everything together.
Inject Coordinator Into ViewModel (Not the View)
Views render. ViewModels coordinate intent.
| |
Not:
| |
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.