Build A SwiftUI NavigationStack Router With Enum Routes
The easiest way to make SwiftUI navigation hard is to let every view invent its own little navigation system. One screen uses a NavigationLink(destination:), another toggles a boolean, a third parses a URL, and suddenly your app can show the same detail screen four different ways.
NavigationStack is much nicer when the app owns a route model. Views can still be simple, but navigation becomes data: push this route, present this sheet, replace the stack with these routes from a deep link.
The code in this post assumes NavigationStack, which is available on iOS 16, iPadOS 16, macOS 13, tvOS 16, watchOS 9, and newer. If your app still supports older OS versions, keep the router idea but put an availability boundary around the SwiftUI navigation shell.
Here is the shape I like for production apps.
Start with route values
A route should describe where to go, not how to build the view. Keep views out of the enum and store stable identifiers instead of whole model objects:
import Foundation
enum AppRoute: Hashable, Codable {
case project(UUID)
case task(projectID: UUID, taskID: UUID)
case settings
}
enum SheetRoute: Hashable, Identifiable {
case newProject
case editProject(UUID)
var id: String {
switch self {
case .newProject:
return "newProject"
case .editProject(let id):
return "editProject-\(id.uuidString)"
}
}
}
Hashable is the important requirement for stack navigation. SwiftUI stores route values in the stack and uses navigationDestination(for:) to turn those values into views.
Codable is not required just to navigate, but it pays off quickly. If the route is codable, you can persist the stack for state restoration, write focused tests around deep links, and log route changes in a readable way. The enum above gets synthesized Hashable and Codable because every associated value supports those protocols.
Notice what is not in the route: Project, Task, ProjectDetailView, closures, bindings, or view models. A route should survive app launches, database refreshes, and model mutations. IDs are boring in exactly the right way.
Typed arrays vs NavigationPath
NavigationStack can bind to a typed collection like [AppRoute], or it can bind to NavigationPath.
For an enum router, prefer the typed array:
@Published var path: [AppRoute] = []
It gives you compiler help, simple tests, direct Codable support, and an obvious place to switch over all possible screens.
NavigationPath is useful when the stack really needs to contain values of different unrelated types. It is type-erased, so you can append a Project.ID, then a SearchQuery, then something else. That flexibility has a cost: your app knows less about the shape of the stack. Apple also exposes a codable representation for NavigationPath, but only paths whose elements can be represented codably are useful for restoration. With [AppRoute], that constraint is visible right on your enum.
If all of your app’s destinations can be described by one route enum, a typed path is usually the calmer option.
Build the router
The router owns navigation state and exposes small verbs. I keep it on the main actor because it drives UI state:
import SwiftUI
@MainActor
final class AppRouter: ObservableObject {
@Published var path: [AppRoute] = []
@Published var sheet: SheetRoute?
func push(_ route: AppRoute) {
path.append(route)
}
func present(_ sheet: SheetRoute) {
self.sheet = sheet
}
func popToRoot() {
path.removeAll()
}
func replace(with routes: [AppRoute]) {
path = routes
sheet = nil
}
}
That is intentionally plain. The router is not a service locator and it should not fetch projects from the database. It coordinates route state. The destination views can load their own data from an ID, or receive dependencies from the environment like the rest of the app.
Wire one root stack
Put one NavigationStack near the scene root, bind it to the router, and register destinations once:
struct AppRootView: View {
@StateObject private var router = AppRouter()
@SceneStorage("navigationPath") private var storedPath = ""
var body: some View {
NavigationStack(path: $router.path) {
ProjectListView()
.navigationDestination(for: AppRoute.self) { route in
destination(for: route)
}
}
.sheet(item: $router.sheet) { sheet in
sheetDestination(for: sheet)
}
.environmentObject(router)
.onAppear {
router.restore(from: storedPath)
}
.onChange(of: router.path) { _ in
storedPath = router.encodedPath()
}
.onOpenURL { url in
if router.open(url) {
storedPath = router.encodedPath()
}
}
}
@ViewBuilder
private func destination(for route: AppRoute) -> some View {
switch route {
case .project(let id):
ProjectDetailView(projectID: id)
case .task(let projectID, let taskID):
TaskDetailView(projectID: projectID, taskID: taskID)
case .settings:
SettingsView()
}
}
@ViewBuilder
private func sheetDestination(for sheet: SheetRoute) -> some View {
switch sheet {
case .newProject:
NewProjectView()
case .editProject(let id):
EditProjectView(projectID: id)
}
}
}
@SceneStorage is per scene, which is what you want for apps that can show multiple windows. Each window gets its own navigation history instead of fighting over a global singleton. The stored value should be lightweight, so this example stores a base64-encoded route array, not model data. Treat it as convenience restoration, not durable storage: the system manages when scene storage is saved, it can disappear when a scene is destroyed, and it is not a place for sensitive data.
The onChange(of:) call above uses the iOS 16-compatible overload. On iOS 17 and newer, you can use the newer zero- or two-argument onChange overloads if your deployment target allows it.
Now child views can navigate without knowing where the stack lives:
struct ProjectListView: View {
@EnvironmentObject private var router: AppRouter
let projects: [ProjectSummary] = ProjectSummary.samples
var body: some View {
List(projects) { project in
Button(project.name) {
router.push(.project(project.id))
}
}
.navigationTitle("Projects")
.toolbar {
Button {
router.present(.newProject)
} label: {
Label("New Project", systemImage: "plus")
}
}
}
}
You can also use NavigationLink(value:) with the same enum:
NavigationLink(project.name, value: AppRoute.project(project.id))
That is still route-based navigation. The important part is that the destination mapping stays at the root instead of being scattered across list rows.
Add restoration
Because [AppRoute] is codable, restoration is small:
extension AppRouter {
func encodedPath() -> String {
guard let data = try? JSONEncoder().encode(path) else {
return ""
}
return data.base64EncodedString()
}
func restore(from encodedPath: String) {
guard
path.isEmpty,
!encodedPath.isEmpty,
let data = Data(base64Encoded: encodedPath),
let restoredPath = try? JSONDecoder().decode([AppRoute].self, from: data)
else {
return
}
path = restoredPath
}
}
In a real app, each destination still has to handle missing data. A restored route might point at a project the user deleted yesterday or an item that no longer syncs to this device. That is not a router failure. Show a friendly empty state and offer a way back.
I usually do not restore sheets unless the product really needs it. Sheets are often transient editing flows, and restoring them can be surprising if the underlying draft no longer exists.
Parse deep links into route stacks
A good deep link parser should return the same route values a button would push. For example:
myapp://projects/PROJECT_IDmyapp://projects/PROJECT_ID/tasks/TASK_IDhttps://example.com/settings
Add URL handling to the router:
extension AppRouter {
@discardableResult
func open(_ url: URL) -> Bool {
guard let routes = routes(for: url) else {
return false
}
replace(with: routes)
return true
}
private func routes(for url: URL) -> [AppRoute]? {
guard url.scheme == "myapp" ||
(url.scheme == "https" && url.host == "example.com") else {
return nil
}
let parts = pathParts(from: url)
if parts.count == 1, parts[0] == "settings" {
return [.settings]
}
if parts.count == 2, parts[0] == "projects",
let projectID = UUID(uuidString: parts[1]) {
return [.project(projectID)]
}
if parts.count == 4, parts[0] == "projects", parts[2] == "tasks",
let projectID = UUID(uuidString: parts[1]),
let taskID = UUID(uuidString: parts[3]) {
return [.project(projectID), .task(projectID: projectID, taskID: taskID)]
}
return nil
}
private func pathParts(from url: URL) -> [String] {
var parts = url.pathComponents.filter { $0 != "/" }
if url.scheme == "myapp", let host = url.host {
parts.insert(host, at: 0)
}
return parts
}
}
The custom-scheme detail is easy to miss: in myapp://projects/123, projects is the host, not the first path component. Normalizing that once keeps the parser readable.
For deeper links, return the whole stack. Opening a task returns both the project route and the task route, so the user lands on the task detail with a useful Back button.
Common pitfalls
The first pitfall is putting full models in the path. If a model’s hash changes after editing, or if the model becomes too large to encode, navigation gets fragile. Put IDs in the route and let screens load the data they need.
The second is creating nested stacks by accident. A detail screen usually should not create another NavigationStack just because it has a list inside it. Nested stacks isolate navigation state and make deep links much harder to reason about.
The third is mixing destination-style links with route-style links. NavigationLink(destination:) is fine for tiny demos, but in an app with deep links and restoration it hides navigation decisions inside leaf views. Prefer route values and one destination table.
The fourth is treating sheets as pushes. If the UI is modal, model it as modal state. Keep stack routes for hierarchy and sheet routes for presentation.
Finally, be careful with singletons. A global router is tempting, but it breaks down quickly in multi-window apps and tests. Own the router at the scene root, pass it through the environment, and inject a fresh one where needed.
Wrap-up
Enum routes make NavigationStack feel less magical. Your app has one route model, one destination mapping, one deep link parser, and one small place to encode and restore navigation state.
The pattern scales because it stays boring: routes are codable data, views are built at the edge, and every way into a screen uses the same path.
Further reading:
- Apple documentation: https://developer.apple.com/documentation/swiftui/navigationstack
- Apple documentation: https://developer.apple.com/documentation/swiftui/navigationpath
- Apple documentation: https://developer.apple.com/documentation/swiftui/view/onopenurl%28perform%3A%29
- Apple documentation: https://developer.apple.com/documentation/swiftui/scenestorage