Navigation is where SwiftUI apps most often end up with code nobody wants to touch. The framework has had three generations of API in five years, most tutorials still show the oldest, and the shape that scales is not the shape you reach for first.
This lesson is the modern one: a stack driven by a path of values, typed routes, and a tab bar where each tab remembers where it was.
Why routes should be values
The API almost everyone meets first is NavigationLink(destination:), which takes the
destination view directly. It works, and it has a flaw that only shows up at size: the destination
is built eagerly, at the moment the link is rendered. A list of fifty orders
constructs fifty detail screens nobody asked for.
NavigationStack with a path inverts that. You append a small value; the
view is built only when it is actually shown:
enum AppRoute: Hashable {
case checkout
case order(id: UUIDString)
}
Two lines, and both destinations the app can push. Hashable is the only requirement
— the stack uses it to tell entries apart.
The second payoff is bigger than the first: navigation becomes data. "The customer is
two levels deep looking at order X" is a [AppRoute], which can be asserted on in a
test, logged with a bug report, or restored after the system terminated your app in the background.
None of that is possible when the position is implied by which views happen to be alive.
The stack
NavigationStack(path: router.path(for: tab)) {
rootScreen(for: tab)
.navigationDestination(for: AppRoute.self) { route in
destination(for: route)
}
Three pieces. The path binding is the stack's contents, and it is two-way — the
system writes to it when the customer swipes back, so your model always reflects the truth.
navigationDestination(for:) registers how to turn a route into a view. And the closure
runs lazily, once, when that route is pushed.
Turning a route into a screen is then an ordinary switch:
@ViewBuilder
private func destination(for route: AppRoute) -> some View {
switch route {
case .checkout:
CheckoutView()
case let .order(id):
OrderConfirmationView(orderID: id)
}
}
Exhaustive, so adding a route stops the build until somebody says what it shows.
@ViewBuilder is what allows the branches to return different view types.
Where to register the destination
A trap worth naming: navigationDestination must be attached inside the
NavigationStack, on a view in its content — not on the stack itself. Put it on the
stack and pushes do nothing at all, with a purple runtime warning if you are lucky and silence if
you are not.
Tabs, and why each needs its own stack
A tab bar is a TabView, and every tab holds a NavigationStack. The
question people get wrong is whether those stacks share a path.
They must not. One shared path means switching tabs inherits the previous tab's stack — tap Orders, open an order, switch to Menu, and the menu appears two levels deep with a back button to somebody else's screen. Per-tab paths are what make each tab remember where it was, which is what every iOS user expects without being able to articulate it.
@MainActor
@Observable
final class AppRouter {
var selectedTab: AppTab = .home
var presentedSheet: AppSheet?
var homePath = NavigationPath()
var menuPath = NavigationPath()
var ordersPath = NavigationPath()
var profilePath = NavigationPath()
NavigationPath is a type-erased stack, so it can hold routes of several different
types at once. A plain [AppRoute] also works and is simpler when — as here — there is
only one route type; the erased version costs nothing and leaves the door open.
Routing without knowing which tab you are in
Four paths means four places to append to, and no feature should have to care which. One method resolves it:
func push(_ route: AppRoute) {
switch selectedTab {
case .home: homePath.append(route)
case .menu: menuPath.append(route)
case .orders: ordersPath.append(route)
case .profile: profilePath.append(route)
}
}
Now the checkout button on the cart sheet works identically whether the customer opened the cart
from Home or from Orders. The feature says router.push(.checkout) and nothing else.
Programmatic navigation
Because the path is just a value, moving around is ordinary code. Three operations cover almost everything.
Push appends, as above. Pop to root assigns an empty path. And replace — the one people reach for late and need more than they expect:
func replaceStack(with route: AppRoute) {
switch selectedTab {
case .home: homePath = NavigationPath([route])
case .menu: menuPath = NavigationPath([route])
case .orders: ordersPath = NavigationPath([route])
case .profile: profilePath = NavigationPath([route])
}
}
This is what runs after a successful payment. Pushing the confirmation screen would leave checkout
underneath it, and the back gesture would return the customer to a payment form for an order that
has already been paid for. Clearing the stack first is the native equivalent of a web router's
replace, and the reason to have it is a real bug rather than tidiness.
Dismissing from inside a screen
A pushed screen that wants to go back does not need the router at all:
@Environment(\.dismiss) private var dismiss
Calling dismiss() pops a pushed view or closes a sheet, whichever it is in. Prefer
it for "close me" and keep the router for "go somewhere specific" — a screen that knows how to
dismiss itself is reusable in both contexts.
Tabs as a closed set
The tabs themselves are an enum, so the tab bar is generated rather than listed:
enum AppTab: Hashable, CaseIterable {
case home
case menu
case orders
case profile
var title: String {
switch self {
case .home: "Home"
case .menu: "Menu"
case .orders: "Orders"
case .profile: "Profile"
}
}
Which means the bar itself is a loop:
.tabItem {
Label(tab.title, systemImage: tab.systemImage)
}
.tag(tab)
Adding a fifth tab is then one enum case and one switch arm the compiler asks for,
rather than four edits in three files that are easy to leave half-finished.
.tag is what connects a tab to the selection binding, and omitting it is a common
cause of "my tab bar does not switch". Label takes an SF Symbol name — a system icon
set that tints with the tab bar's colours, scales with Dynamic Type and carries accessibility
descriptions. The React Native version of this app uses emoji and comments that a real app would use
an icon set; this is that app.
What is deliberately not a tab
The cart. It is a toolbar button that opens a sheet, so the customer never loses the screen they were on — tapping a fifth tab to check the basket and then navigating back to the menu is the friction this avoids. Worth stating because "is this a destination or an overlay?" is a design decision that navigation code will otherwise make for you by default.
Modals belong in the same model
A sheet is navigation too, and the instinct is to give each one a boolean. Three
@State private var isXPresented flags allow two sheets to be "presented" at once,
which SwiftUI resolves by showing one and silently ignoring the other — a bug that looks like a tap
being dropped. One optional enum makes that unrepresentable:
enum AppSheet: Identifiable, Hashable {
case cart
case signIn
case register
var id: Self { self }
}
var id: Self { self } is the idiom for making an enum Identifiable
when the case itself is the identity. Lesson 8 goes into what .sheet(item:) then buys
you; the point here is that modal presentation lives in the router beside the stacks, because it is
the same question.
The navigation bar
The bar is configured from the content, not from the stack — another consequence of views being values. A screen states what it wants and the nearest stack obeys:
.toolbar { cartToolbarItem }
.toolbarBackground(Theme.colors.surfaceInverse, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.dark, for: .navigationBar)
Two of those need explaining. .toolbarBackground(.visible, …) is required because
iOS hides the bar's background until content scrolls under it — without it, a dark brand bar appears
only when the customer scrolls, which reads as a rendering bug. And
.toolbarColorScheme(.dark, …) tells the system the bar is dark so it draws the title
and buttons light; it is about the bar, not the app.
Titles are set per screen with .navigationTitle(), and
.navigationBarTitleDisplayMode(.inline) chooses between the large scrolling title and
the compact one. Both belong on the screen, for the same reason: the screen knows what it is called,
the container does not.
Deep links
Because routes are values, a URL handler is a parser and nothing more: turn the URL into an
AppRoute, select the right tab, append. onOpenURL is where that goes.
The demo app declares a URL scheme already — not for deep linking, but because Stripe needs
somewhere to return to after a bank's 3D Secure page. That is lesson 17; the point here is that the
scheme is registered in Info.plist and the routing model is ready for it.
What to avoid
Two patterns that look reasonable and are not.
Do not nest a NavigationStack inside another one. It is the most
common cause of double navigation bars and back buttons that go to the wrong place. One stack per
tab, and screens inside it are plain views.
Do not put a TabView inside a NavigationStack. It is
the wrong way round — the tabs are the outer structure and each one owns a stack, not the other way
about. Getting this backwards produces an app where the navigation bar belongs to the wrong screen.
One more thing the router makes easy
Because navigation is state in an object rather than a side effect in a view, a few things that are normally awkward become ordinary. Popping to root and switching tab is two calls from anywhere — it is what the "Back to the menu" button on the receipt screen does. Presenting sign-in from three different places needs no shared parent, because nothing is being passed down. And a test can drive the router directly and assert on the resulting path without rendering a single view.
The cost is one more type to hold in your head, and a rule to keep: features ask the router to navigate; they do not read its state to decide what to draw. Once a screen starts branching on where it is in the stack, the router has become a second, worse source of truth.
Next
Navigation usually starts from a list, and lists are where SwiftUI performance is won or lost.
The next lesson is List against LazyVStack against
LazyVGrid, why identity matters more than it looks, and what the framework does instead
of React's memo.