DEV Community

Daniil Kornilov
Daniil Kornilov

Posted on Edited on

SwiftUI navigation patterns: paths, tabs and deep links

Disclosure: This article was created or edited with AI assistance. Revised on 13 October 2026 to remove promotional links and unsupported outcome claims. It is educational guidance, not a verified personal case study or a measured result. Examples are illustrative; use only your own documented experience and measurements.

The sections below present options and illustrative examples. Adapt them to the requirements of your project or role; they do not establish a universal rule or promise a result.

NavigationStack Basics

NavigationStack supports stack-based navigation. Consider NavigationSplitView for a multi-column interface.

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Profile", value: Route.profile)
                NavigationLink("Settings", value: Route.settings)
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .profile: ProfileView()
                case .settings: SettingsView()
                case .detail(let id): Text("Detail: \(id)")
                }
            }
        }
    }
}

enum Route: Hashable {
    case profile
    case settings
    case detail(id: String)
}
Enter fullscreen mode Exit fullscreen mode

Value-based links are useful when you need a controllable path. Destination-based links remain valid for simpler flows.

Programmatic Navigation with NavigationPath

The real power comes when you control the navigation stack programmatically.

struct AppView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            HomeView(path: $path)
                .navigationDestination(for: Route.self) { route in
                    destinationView(for: route)
                }
        }
    }

    @ViewBuilder
    func destinationView(for route: Route) -> some View {
        switch route {
        case .profile:
            ProfileView(path: $path)
        case .settings:
            SettingsView()
        case .detail(let id):
            DetailView(id: id, path: $path)
        }
    }
}

// Push programmatically from anywhere
struct HomeView: View {
    @Binding var path: NavigationPath

    var body: some View {
        Button("Go to Profile") {
            path.append(Route.profile)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pro tip: You can pop to root by resetting the path:

path = NavigationPath() // Pops to root instantly
Enter fullscreen mode Exit fullscreen mode

The Router Pattern

For larger apps, centralize navigation logic in a Router:

@Observable
class Router {
    var path = NavigationPath()

    func push(_ route: Route) {
        path.append(route)
    }

    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }

    func popToRoot() {
        path = NavigationPath()
    }

    func reset(to route: Route) {
        path = NavigationPath()
        path.append(route)
    }
}

// Inject via environment
struct AppView: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    destinationView(for: route)
                }
        }
        .environment(router)
    }
}

// Use from any child view
struct DetailView: View {
    @Environment(Router.self) var router
    let id: String

    var body: some View {
        VStack {
            Text("Detail: \(id)")
            Button("Back to Home") {
                router.popToRoot()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern gives you clean, testable navigation without the complexity of a full Coordinator pattern.

Tab-Based Navigation

Most real apps combine tabs with navigation stacks:

struct MainTabView: View {
    @State private var selectedTab = 0
    @State private var homeRouter = Router()
    @State private var profileRouter = Router()

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack(path: $homeRouter.path) {
                HomeView()
                    .navigationDestination(for: Route.self) { route in
                        // home destinations
                    }
            }
            .tabItem { Label("Home", systemImage: "house") }
            .tag(0)
            .environment(homeRouter)

            NavigationStack(path: $profileRouter.path) {
                ProfileView()
                    .navigationDestination(for: Route.self) { route in
                        // profile destinations
                    }
            }
            .tabItem { Label("Profile", systemImage: "person") }
            .tag(1)
            .environment(profileRouter)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Important: Each tab needs its own NavigationStack and Router. Sharing a single path across tabs leads to bugs.

Deep Linking

Handle deep links by parsing URLs into routes and pushing them onto the stack:

struct AppView: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeView()
                .navigationDestination(for: Route.self) { route in
                    destinationView(for: route)
                }
        }
        .environment(router)
        .onOpenURL { url in
            handleDeepLink(url)
        }
    }

    func handleDeepLink(_ url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
              let host = components.host else { return }

        router.popToRoot()

        switch host {
        case "profile":
            router.push(.profile)
        case "item":
            if let id = components.queryItems?.first(where: { $0.name == "id" })?.value {
                router.push(.detail(id: id))
            }
        default:
            break
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Sheet and Modal Navigation

Sheets need separate handling. Use an enum for sheet types:

enum SheetType: Identifiable {
    case newPost
    case editProfile
    case settings

    var id: String { String(describing: self) }
}

struct HomeView: View {
    @State private var activeSheet: SheetType?

    var body: some View {
        VStack {
            Button("New Post") { activeSheet = .newPost }
            Button("Edit Profile") { activeSheet = .editProfile }
        }
        .sheet(item: $activeSheet) { sheet in
            switch sheet {
            case .newPost: NewPostView()
            case .editProfile: EditProfileView()
            case .settings: SettingsView()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes

  1. Choosing links without considering state — Use value-based links when you need a controllable path.
  2. Unintentionally nesting NavigationStacks — Give each independent flow a clearly owned stack.
  3. Unclear state ownership — Choose and document where navigation state lives.
  4. Not making Route Hashable — NavigationPath requires Hashable conformance
  5. Forgetting .navigationDestination — Register all route types at the NavigationStack level

Summary

Modern SwiftUI navigation is powerful when you follow these principles:

  • Use NavigationStack with NavigationPath for programmatic control
  • Centralize navigation in a Router object
  • Separate navigation stacks per tab
  • Handle deep links by mapping URLs to routes
  • Use sheet(item:) for modal presentation

Scope of the code examples

These are separate teaching snippets, not a complete tested application or a production-ready package. Match availability to your deployment target, provide omitted model/service definitions, and check errors, cancellation, accessibility and persistence before adapting them.

NavigationStack and NavigationSplitView serve different layouts; destination-based links remain valid. See Apple's navigation guide and NavigationSplitView. Deep-link examples also need scheme/host validation and an explicit policy for unavailable or unauthorized destinations.

Top comments (0)