Start a project

Article · iOS · SwiftUI · Navigation · 14 min read

SwiftUI NavigationStack and deep links

Treat the navigation path as state you own, not as a side effect of tapping a link. In SwiftUI, a NavigationStack with a typed, Codable route list gives you one value you can push, pop, write tests against, fill from a deep link, and restore after a relaunch. If the path is only implied by a tree of NavigationLink views, a URL or a widget tap has nowhere honest to land.

On this page 10 sections
  1. Treat the path as state, not as a side effect
  2. Typed routes that encode and decode cleanly
  3. Opening a URL into a path
  4. State restoration after a relaunch
  5. Sheets, covers and the stack
  6. Tab views and more than one stack
  7. Testing navigation without tapping through the UI
  8. Common broken-stack bugs
  9. Key takeaways
  10. 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 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.

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:

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:

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:

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:

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

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.

All articles · Swift A–Z · Apps

Questions, or an app in mind?

Let's bring your ideas to life. Whether you're envisioning a game-changing Apple Watch app or a transformative iOS experience, I'm here to make it happen.

Start a project