On this page 10 sections
- Treat the path as state, not as a side effect
- Typed routes that encode and decode cleanly
- Opening a URL into a path
- State restoration after a relaunch
- Sheets, covers and the stack
- Tab views and more than one stack
- Testing navigation without tapping through the UI
- Common broken-stack bugs
- Key takeaways
- Frequently asked questions
The existing SwiftUI state and architecture guide mentions navigation only in passing. This one is about the stack itself: how to model it, how to open a URL into it, how to put it back after the process is killed, and the broken-stack bugs that usually mean two things are trying to own the same path.
Treat the path as state, not as a side effect
NavigationView pushed views by nesting them. That worked for a short trail and fell apart the moment you needed to stand on a specific screen from outside the view tree. NavigationStack, introduced in iOS 16, inverts this. You give it a path — a value — and a destination for each type on that path. The visible screens are a function of the path.
Two shapes of path are worth considering:
- A typed array, such as
[Route], whereRouteis an enum you control. This is the form I reach for first. It isHashable, it can beCodable, it is easy to print in a test, and a deep link is just aRouteyou append or assign. - A
NavigationPath, which can hold mixedHashablevalues. Use it when destinations of different types truly share one stack and you do not want a single enum. Restoring it means going through itsCodableRepresentation, which is more ceremony than an array of one type.
A minimal stack with a typed path looks like this:
import SwiftUI
enum Route: Hashable, Codable {
case order(id: String)
case customer(id: String)
case settings
}
struct RootView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
OrderList(path: $path)
.navigationDestination(for: Route.self) { route in
switch route {
case .order(let id):
OrderDetail(id: id, path: $path)
case .customer(let id):
CustomerDetail(id: id)
case .settings:
SettingsView()
}
}
}
}
}Pushing is a state change, path.append(.order(id: id)). Popping to the root is path.removeAll(). Replacing the trail after a deep link is an assignment, not a second stack. That is the whole point: navigation becomes data.
Keep the path next to the other feature state, not hidden inside a leaf view. If several screens need to push, put the path on the observable model that already owns the feature, as in the architecture guide. A leaf that constructs its own NavigationStack is how you lose the path you were trying to share.
Typed routes that encode and decode cleanly
The route enum is the contract between URLs, widgets, tests and the stack. Design it as data, not as a view.
- Identify things by a stable id, not by a whole model.
.order(id:)survives a relaunch;.order(Order)does not, unless you are prepared to encode the full value and to deal with a stale snapshot. - Keep associated values
Codableand small. Strings, identifiers and simple enums are enough. If you need a UUID, create it with a real generator — the UUID Generator is fine for fixtures — and store the string or theUUIDitself, which already encodes. - Do not put transient UI in the route. A selected tab inside the detail screen, a scroll offset, or “the editor is focused” is view state, not a destination.
- One destination per case. If two screens share a layout but not a meaning, they can share a view and still be two cases. If they are the same screen, they should be one case, or you will register two destinations that fight.
Encoding and decoding should be boring. A compiler-synthesised Codable on a small enum is enough for state restoration and for logs. If you also parse URLs, write that as a failable initialiser and keep it next to the enum, not inside a view:
extension Route {
init?(url: URL) {
guard url.scheme == "myapp" else { return nil }
let parts = url.pathComponents.filter { $0 != "/" }
switch (url.host, parts) {
case ("orders", let ids) where ids.count == 1:
self = .order(id: ids[0])
case ("customers", let ids) where ids.count == 1:
self = .customer(id: ids[0])
case ("settings", []):
self = .settings
default:
return nil
}
}
var url: URL? {
switch self {
case .order(let id):
URL(string: "myapp://orders/\(id)")
case .customer(let id):
URL(string: "myapp://customers/\(id)")
case .settings:
URL(string: "myapp://settings")
}
}
}When you are designing or debugging those URLs, parse them as data. The URL Encoder and Parser shows scheme, host, path and query clearly; the Regex Tester is useful if you inherit a messier pattern than the one above. Confirm a sample of real URLs before you wire them to the stack. If your associated values need percent-encoding, encode them — do not concatenate a raw name into a path and hope.
Universal Links and custom schemes should resolve to the same Route. The difference is only how the system delivers the URL. Once you have a Route, the stack does not care which door the user came through.
Opening a URL into a path
Handle the URL at the root that owns the stack, typically with .onOpenURL or, for Universal Links and incoming activities, .onContinueUserActivity. Map the URL to a route, then decide whether to replace the path or to append.
Replacing is usually right for a deep link: the user asked for a specific screen, not for that screen on top of whatever they were doing yesterday.
.onOpenURL { url in
guard let route = Route(url: url) else { return }
switch route {
case .settings:
path = [.settings]
case .order, .customer:
path = [route]
}
}Appending is right when the link is a step in a flow the user is already in, and you can see that the current path is a valid prefix. Blind path.append from a cold start is how you end up with a back button to an empty or half-built screen.
A few checks before you write the path:
- The user may already be on that route. Assigning the same path again should be a no-op, not a duplicate push.
- The tab or split view might be wrong. If orders live in a tab, select that tab first, then set that tab’s path. A URL handled only at the scene root, with a single path shared across tabs, will show the right screen in the wrong tab or in none of them.
- Invalid URLs should do nothing obvious and log a reason. Do not push a placeholder destination that says “unknown” if you would not want that in a screenshot. Drop the link, or take the user to a safe root.
- Async work comes after the path is set. If the detail screen needs to load the order, the screen should load it. Do not block navigation on the network; the path is the request to be there.
App Intents, widgets and Live Activities should produce the same URLs or the same Route values. If a widget opens the app with a URL, you already have the handler. The widgets, App Intents and Live Activities guide covers those entry points; the job here is to make sure they all collapse into the path.
State restoration after a relaunch
If the user is three screens in, a system-initiated kill should not drop them on the root. Because the path is data, restoration is an encode and a decode.
The lightest approach for a typed array is to persist it with the scene, for example with SceneStorage and a small wrapper, or by writing the encoded path in your existing scene-state code. NavigationPath exposes this officially as codable (the CodableRepresentation): you store that representation and rebuild the path from it on the next launch.
A typed array can be stored as JSON:
extension Array where Element == Route {
static func restore(from data: Data?) -> [Route] {
guard let data else { return [] }
return (try? JSONDecoder().decode([Route].self, from: data)) ?? []
}
var stored: Data? {
try? JSONEncoder().encode(self)
}
}Restore at the same root that owns the path, after you know which tab you are on, and before the first frame if you can. A flash of the root followed by a jump to the detail is worse than a short wait on the restored screen’s own loading state.
Be conservative about what you restore:
- Drop a path that no longer makes sense. An order id that 404s should land on the list, or on a dedicated missing-state view, not on a broken detail. The screen handles the missing record; the path can be reset from there.
- Do not restore modal state unless you mean to. A half-finished editor in a sheet is often better discarded than brought back stale. The stack and the sheet are different state.
- Version the payload if you think the enum will change. A failed decode should yield an empty path, not a crash on launch.
SceneStorage is for small, scene-scoped identifiers, not for document contents.
Sheets, covers and the stack
Sheets, full-screen covers and popovers are not entries in the navigation path. They are presented state on a particular screen, or on the root. Mixing the two is a common source of “the sheet vanished when I pushed” and “the back button closed the modal”.
A clean split:
- The path is the trail of pushed screens in the stack.
- A Boolean or an optional item on the current screen, or on the feature model, drives
.sheetand.fullScreenCover.
If a sheet itself needs a stack — a multi-step composer, a filter flow — give the sheet its own NavigationStack and its own path, owned by that presented value. Do not append those steps to the parent’s path. When the sheet dismisses, its path goes with it.
.toolbar buttons that “navigate” by setting a sheet item should not also append a route. Pick one presentation.
navigationDestination(isPresented:) and item-based destinations are easy to overuse on top of a path-based stack. If you already have [Route], prefer appending a case, and keep isPresented for true modals.
Tab views and more than one stack
Each tab that can push should have its own NavigationStack and its own path. A single path shared by three tabs will leak screens across tabs, or pop one tab when you meant to pop another.
A simple model is an array, or a small struct, of paths, plus the selected tab:
@Observable
final class AppNavigation {
var selectedTab: Tab = .orders
var ordersPath: [Route] = []
var customersPath: [Route] = []
var accountPath: [Route] = []
func open(_ route: Route) {
switch route {
case .order:
selectedTab = .orders
ordersPath = [route]
case .customer:
selectedTab = .customers
customersPath = [route]
case .settings:
selectedTab = .account
accountPath = [route]
}
}
}The TabView binds selectedTab. Each tab’s root reads only its own path. Deep links go through open(_:), which is also what a test will call.
Split views follow the same idea: the sidebar selection is one piece of state, the detail stack is another. Do not encode the sidebar in the detail path unless they are genuinely the same trail.
Nested stacks inside a tab — a stack in a card in a stack — are almost always a mistake. If you think you need one, first check whether a sheet with its own stack would be clearer.
Testing navigation without tapping through the UI
Because the path is a value, most navigation tests should not launch a simulator and tap. Put the path on a model, call the same methods a view would call, and assert the result.
import Testing
@Test func deepLinkOpensOrderOnOrdersTab() {
let nav = AppNavigation()
nav.open(.order(id: "A-1024"))
#expect(nav.selectedTab == .orders)
#expect(nav.ordersPath == [.order(id: "A-1024")])
}
@Test func unknownURLDoesNotChangePath() {
let nav = AppNavigation()
nav.ordersPath = [.settings]
if let url = URL(string: "myapp://unknown/x"), let route = Route(url: url) {
nav.open(route)
}
#expect(nav.ordersPath == [.settings])
}That second test is the important kind: invalid input leaves the user where they were. You can also test encoding and decoding of [Route], and that Route(url:) is the inverse of route.url for every case.
UI tests still have a place for one or two journeys — a Universal Link from a cold start, the detail title — but they are not how you pin down the routing table. See the Swift Testing guide for #expect and for injecting the model.
If a destination is loaded with async work, test the model’s load and error states separately. Navigation tests should not wait on the network; they only claim that the path was set. Cancellation belongs with your networking code, and more generally with Swift concurrency in practice.
Common broken-stack bugs
These are the failures I see most often, and they usually look like “SwiftUI navigation is buggy” when they are a second owner of the path:
- Two
NavigationStacks for the same trail. A root stack, plus another inside a destination, means the inner back button pops a path the outer stack does not know about. One stack per trail. NavigationLinkwith a destination closure on a stack that also has a path binding. The link pushes a view the path cannot describe, so a deep link cannot recreate it, and popping can desync. PreferNavigationLink(value:)andnavigationDestination(for:).- Several
navigationDestination(for: Route.self)modifiers for the same type in the same stack. The destination becomes ambiguous. Register one mapping at the root. - Registering destinations only on a leaf. If the leaf is not in the tree at the moment you set the path (a deep link at launch, for example), the stack has no destination to build. Put the mapping high enough that it is always present.
- Appending a deep link on top of a restored path and creating a trail that never existed. Decide: replace or carefully append.
- Using a model as the route and then mutating it. Equality and hashing change mid-stack, and SwiftUI loses track of the screen. Route by id.
- Hiding a
UINavigationControllerbar and also showing a SwiftUI bar, or the reverse, in a mixed app. One bar per visible stack, as in the UIKit to SwiftUI migration guide. - A
pathin the view as@Stateand a copy on a model. One owner only.
When a bug appears, log the path, including the URL you received. If you cannot print the path, it is not yet state you own.
Key takeaways
- Give
NavigationStacka typed path you control, preferably aCodableenum array, and treat every push and pop as a state change. - Parse deep links into the same route type the stack uses. Prefer replacing the path on a cold link, and select the right tab first.
- Persist only identifiers, restore at the root that owns the path, and drop a path that no longer decodes or no longer exists on the server.
- Keep sheets and covers off the path. A presented flow gets its own stack.
- One stack and one path per tab or trail. Tests should set and read that path without tapping the UI.
- Most broken stacks are two owners, a destination registered in the wrong place, or a
NavigationLinkthat never wrote to the path.
If you want a second pair of eyes on a routing table or a Universal Link setup, tell me about it.
Frequently asked questions
How does NavigationStack work in SwiftUI?
You provide a path value and a destination for each type that can appear on it. The visible pushed screens are a function of that path. Changing the path is how you navigate, which is what makes deep links, tests and restoration possible. It replaced the older NavigationView nesting style for this kind of app.
How do you handle deep links in SwiftUI?
Handle the incoming URL at the root that owns the stack, convert it to a route, select the right tab if you have tabs, and assign the path. Use the same conversion for custom schemes, Universal Links, widgets and App Intents so every door leads to the same state.
What is the difference between NavigationStack and NavigationView?
NavigationView built a trail by nesting destination views. It is deprecated. NavigationStack takes a path and destination mappings, which scales to deep links and restoration. If you are starting a flow today, use NavigationStack.
How do you restore navigation state in a SwiftUI app?
Encode the path when the scene goes to the background, and decode it when the scene comes back. A typed Codable array can be stored as JSON; a NavigationPath can be stored through its CodableRepresentation. Restore identifiers, not whole models, and fall back to the root if decoding fails or a record is gone.
How do you pass data through a SwiftUI navigation path?
Put a stable identifier in the route, and let the destination load its own data from that id. Passing a large, mutable model as the route makes hashing and restoration fragile. If two screens need to share a model, inject the model into the tree and let the route only say which screen to show.
Written by Lee Johnson. Published 10 November 2026.