The Problem
SwiftUI navigation doesn’t scale.
NavigationStack works for simple apps. Define some destinations, register them with .navigationDestination(for:), push views onto a path. But the moment your app grows beyond a handful of screens, cracks appear.
Views start deciding where to go next. Navigation logic gets duplicated. Sheets and full-screen covers are managed with scattered boolean bindings. Deep links have nowhere clean to land. And if your app is modular, split into separate Swift packages with independent feature teams, the question of “how does module A navigate to module B when they can’t import each other?” has no obvious answer.
Everyone agrees on the problem. The disagreement is about what to do about it.
Approach 1: Shared Router, Shared Destinations
The most common pattern I’ve seen is a single Router that owns all navigation state, paired with shared destination enums that every module can see.
Felipe Espinoza’s CraftingSwift demo is a clean example. His app is modularised horizontally by architectural layer. The Navigation package contains the Router, all destination enums, and the deep link parser. Every feature module imports it, so views can navigate directly:
| |
One line. The view declares where it goes. Adding a route is straightforward.
The trade off is coupling. Every destination for every feature lives in the same enums. Adding a screen in one module changes the shared type, triggering a recompile of every module that depends on it. Any view can navigate to any destination with no compile time guardrails. At five modules, that’s fine. At fifteen modules across multiple teams, the shared destination file becomes a bottleneck. Merge conflicts, unclear ownership, and a growing enum that everyone edits.
For a solo developer or a small team, this is a pragmatic choice. Felipe also introduces a smart Screen vs View split for deep linking — Screens fetch data when given an ID, Views take data directly — which is worth borrowing regardless of your architecture.
Approach 2: Navigation Frameworks
At the other end of the spectrum are full frameworks that abstract NavigationStack entirely.
Michael Long’s Navigator is a well documented example. Destinations are enums that conform to NavigationDestination and provide their own view body, no .navigationDestination(for:) registration needed. The framework supports programmatic push, sheets, full-screen covers, checkpoints (named return points with value passing), deep linking via navigationSend, and state restoration. For modular apps, it offers NavigationProvidedDestination, a shared module defines destinations without view bodies, and the app target registers the views.
SwiftfulRouting takes things further. It replaces NavigationStack with its own RouterView and gives every screen a router via @Environment that handles push, sheet, fullScreenCover, alerts, modals, transitions, and full module swaps. Views build destinations at call time via closures rather than defining them as enum cases:
| |
No destination enums, no registration. It also supports screen queues (useful for variable length onboarding flows), module switching with state restoration, and built-in analytics logging.
The trade-off with both is dependency. Your navigation layer is coupled to someone else’s abstraction. The concepts they introduce, checkpoints, managed stacks, screen queues, are framework specific. They don’t transfer if you move away. If SwiftUI’s navigation APIs change significantly, you wait for the library to update.
If you want a batteries included solution, both are strong choices. But they solve a different problem than understanding navigation architecture from first principles.
Approach 3: Per-Module Coordinators
The traditional iOS approach, and what I argued for in a previous post, is the coordinator pattern. A dedicated coordinator per flow that owns navigation logic and keeps views free of routing concerns.
This translates less cleanly to SwiftUI than it did in UIKit. NavigationStack is value driven. You append to a path, you don’t push a view controller. So the coordinator becomes an @Observable class holding a NavigationPath with methods like:
| |
That’s a pass-through. The coordinator doesn’t decide anything, it wraps a push with an extra layer of indirection. When every coordinator method maps 1:1 to a destination, the coordinator isn’t coordinating. It’s ceremony.
Coordinators earn their keep when the next screen depends on authentication state, experiment assignment, or the result of an async operation. But applying the pattern to every module by default front-loads complexity that most flows never need.
Where This Led Me
After studying these approaches, I built a demo project that takes a different path:
- Vertical modularisation — split by feature, not by layer. Each module owns its own destination types. No shared destination enum.
- A dumb generic Router per module — 30 lines of code. Holds navigation state, nothing more.
- Closure-based view factories — modules express intent, the composition root decides what happens. The compiler enforces module isolation.
- One AppCoordinator at the app level — the only coordinator in the project. It handles logout, deep links, onboarding gates, real orchestration that crosses module boundaries. No coordinator per feature module.
The next post walks through the full implementation with a demo project you can clone with the Router, the composition root, the AppCoordinator, deep linking, and tests.
Understanding the landscape, and when each approach fits, is what makes the difference between following a pattern and choosing an architecture. Start with the simplest thing that works. Add structure when the codebase tells you it needs it.