Skip to content

SwiftUI Navigation — NavigationStack, NavigationLink, and Paths

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about SwiftUI Navigation. We cover key concepts, practical examples, and best practices to help you master this topic.

SwiftUI navigation uses the NavigationStack (iOS 16+) to manage a stack of views, providing declarative push/pop navigation, programmatic path control, and deep linking support without needing UIKit's UINavigationController.

What You'll Learn

  • NavigationStack and NavigationLink basics
  • Programmatic navigation with NavigationPath
  • Modal presentation with .sheet and .fullScreenCover
  • Passing data between navigation destinations
  • Deep linking and custom navigation paths

Why It Matters

Navigation is fundamental to multi-screen apps. UIKit's navigation was imperative — you pushed view controllers and managed the stack manually. SwiftUI's NavigationStack is declarative: you describe what the navigation should look like, and SwiftUI handles the transitions. NavigationPath gives you programmatic control for deep linking and complex flows.

Real-World Use

A shopping app uses NavigationStack with a list of products. Tapping a product pushes a detail view. From the detail, users can tap "Add Review" to push a review form. NavigationPath tracks the full path, and tapping the "Back to Products" button programmatically pops to root. A deep link from a notification directly opens the product detail.

Learning Path

flowchart LR
  A[SwiftUI Data Flow
Lesson 26] --> B[SwiftUI Navigation
You are here] B --> C[SwiftUI Lists
Lesson 28] B --> D[SwiftUI Animations
Lesson 29] style B fill:#f90,color:#fff

NavigationStack wraps a root view and manages the navigation stack. NavigationLink presents a destination view.

import SwiftUI

struct Product: Identifiable, Hashable {
    let id: Int
    let name: String
    let price: Double
    let description: String
}

struct ProductListView: View {
    let products = [
        Product(id: 1, name: "MacBook Pro", price: 2499.00, description: "Powerful laptop for professionals."),
        Product(id: 2, name: "iPad Air", price: 599.00, description: "Versatile tablet for creativity."),
        Product(id: 3, name: "AirPods Pro", price: 249.00, description: "Premium wireless earbuds."),
        Product(id: 4, name: "Apple Watch", price: 399.00, description: "Health and fitness tracker.")
    ]

    var body: some View {
        NavigationStack {
            List(products) { product in
                NavigationLink(value: product) {
                    ProductRow(product: product)
                }
            }
            .navigationTitle("Products")
            .navigationDestination(for: Product.self) { product in
                ProductDetailView(product: product)
            }
        }
    }
}

struct ProductRow: View {
    let product: Product

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text(product.name)
                    .font(.headline)
                Text("$\(product.price, specifier: "%.2f")")
                    .foregroundColor(.secondary)
            }
            Spacer()
            Image(systemName: "chevron.right")
                .font(.caption)
                .foregroundColor(.gray)
        }
        .padding(.vertical, 4)
    }
}

struct ProductDetailView: View {
    let product: Product

    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "shippingbox")
                .font(.system(size: 80))
                .foregroundColor(.blue)

            Text(product.name)
                .font(.largeTitle)
                .fontWeight(.bold)

            Text("$\(product.price, specifier: "%.2f")")
                .font(.title2)
                .foregroundColor(.green)

            Text(product.description)
                .font(.body)
                .multilineTextAlignment(.center)
                .padding(.horizontal)

            NavigationLink(value: product.id) {
                Label("Write a Review", systemImage: "square.and.pencil")
            }
            .buttonStyle(.bordered)
        }
        .padding()
        .navigationTitle("Product Detail")
        .navigationDestination(for: Int.self) { productID in
            ReviewFormView(productID: productID)
        }
    }
}

struct ReviewFormView: View {
    let productID: Int

    var body: some View {
        Form {
            Text("Review for Product #\(productID)")
            TextField("Your review", text: .constant(""))
            Button("Submit") { print("Review submitted") }
        }
        .navigationTitle("Write Review")
    }
}

NavigationLink with value-based destinations is preferred over string-based navigation. Each value type gets a navigationDestination(for:) modifier that maps to the appropriate view.

Programmatic Navigation with NavigationPath

NavigationPath gives you control over the navigation stack programmatically.

import SwiftUI

struct ProgrammaticNavigationView: View {
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            VStack(spacing: 20) {
                Text("Navigation Control Center")
                    .font(.title)

                Button("Go to Step 1") {
                    navigationPath.append("Step1")
                }

                Button("Go to Step 2") {
                    navigationPath.append("Step2")
                }

                Button("Go to Detail #5") {
                    navigationPath.append(5)
                }

                Button("Push Multiple Screens") {
                    navigationPath.append("Step1")
                    navigationPath.append("Step2")
                    navigationPath.append(99)
                }

                Button("Go Back One") {
                    navigationPath.removeLast()
                }

                Button("Go to Root") {
                    navigationPath.removeLast(navigationPath.count)
                }

                Text("Current path count: \(navigationPath.count)")
                    .foregroundColor(.secondary)
            }
            .navigationTitle("Navigation Demo")
            .navigationDestination(for: String.self) { step in
                StepView(step: step, path: $navigationPath)
            }
            .navigationDestination(for: Int.self) { number in
                NumberDetailView(number: number, path: $navigationPath)
            }
        }
    }
}

struct StepView: View {
    let step: String
    @Binding var path: NavigationPath

    var body: some View {
        VStack(spacing: 16) {
            Text("You are at \(step)")
                .font(.largeTitle)

            Button("Go Forward") {
                path.append("\(step)_extended")
            }

            Button("Go Back") {
                path.removeLast()
            }
        }
        .navigationTitle("Step \(step)")
    }
}

struct NumberDetailView: View {
    let number: Int
    @Binding var path: NavigationPath

    var body: some View {
        VStack(spacing: 16) {
            Text("Detail #\(number)")
                .font(.largeTitle)

            Button("Push Next (#\(number + 1))") {
                path.append(number + 1)
            }

            Button("Back to Root") {
                path.removeLast(path.count)
            }
        }
        .navigationTitle("Number \(number)")
    }
}

NavigationPath stores a type-erased array of hashable values. You can mix different types (String, Int, custom structs) in the same path.

Use .sheet for modal views and .fullScreenCover for full-screen modal presentations.

import SwiftUI

struct ModalPresentationView: View {
    @State private var showSheet = false
    @State private var showFullScreen = false
    @State private var selectedItem: String?

    var body: some View {
        VStack(spacing: 20) {
            Button("Show Sheet") {
                showSheet = true
            }
            .buttonStyle(.borderedProminent)

            Button("Show Full Screen Cover") {
                showFullScreen = true
            }
            .buttonStyle(.borderedProminent)

            Button("Show Sheet with Item") {
                selectedItem = "Selected Item #42"
            }
            .buttonStyle(.bordered)
        }
        .sheet(isPresented: $showSheet) {
            SheetContentView(isPresented: $showSheet)
        }
        .fullScreenCover(isPresented: $showFullScreen) {
            FullScreenContentView(isPresented: $showFullScreen)
        }
        .sheet(item: $selectedItem) { item in
            VStack(spacing: 20) {
                Text(item)
                    .font(.title)
                Button("Dismiss") {
                    selectedItem = nil
                }
            }
            .padding()
        }
    }
}

struct SheetContentView: View {
    @Binding var isPresented: Bool

    var body: some View {
        NavigationStack {
            VStack(spacing: 16) {
                Text("This is a sheet")
                    .font(.title)
                Text("Drag down to dismiss or tap the button.")
                    .foregroundColor(.secondary)
            }
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Done") {
                        isPresented = false
                    }
                }
            }
        }
    }
}

struct FullScreenContentView: View {
    @Binding var isPresented: Bool

    var body: some View {
        VStack(spacing: 20) {
            Text("Full Screen Cover")
                .font(.largeTitle)
                .fontWeight(.bold)

            Image(systemName: "star.fill")
                .font(.system(size: 60))
                .foregroundColor(.yellow)

            Button("Dismiss") {
                isPresented = false
            }
            .buttonStyle(.borderedProminent)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color.blue.opacity(0.1))
    }
}

Use isPresented for boolean-controlled presentation and item for optional-based presentation that provides data to the presented view.

NavigationStack can be nested inside TabView for multi-section apps.

import SwiftUI

struct TabNavigationView: View {
    @State private var selectedTab = 0

    var body: some View {
        TabView(selection: $selectedTab) {
            NavigationStack {
                HomeTabView()
            }
            .tabItem {
                Label("Home", systemImage: "house")
            }
            .tag(0)

            NavigationStack {
                SearchTabView()
            }
            .tabItem {
                Label("Search", systemImage: "magnifyingglass")
            }
            .tag(1)

            NavigationStack {
                ProfileTabView()
            }
            .tabItem {
                Label("Profile", systemImage: "person.circle")
            }
            .tag(2)
        }
    }
}

struct HomeTabView: View {
    let categories = ["Technology", "Sports", "Music", "Food"]

    var body: some View {
        List(categories, id: \.self) { category in
            NavigationLink(category, value: category)
        }
        .navigationTitle("Home")
        .navigationDestination(for: String.self) { category in
            Text("Category: \(category)")
                .font(.title)
        }
    }
}

struct SearchTabView: View {
    var body: some View {
        Text("Search View")
            .navigationTitle("Search")
    }
}

struct ProfileTabView: View {
    var body: some View {
        Text("Profile View")
            .navigationTitle("Profile")
    }
}

Each tab has its own NavigationStack, so navigation state is preserved independently.

Deep Linking

Handle deep links by observing NavigationPath changes.

import SwiftUI

struct DeepLinkHandlingView: View {
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            List {
                Text("Deep Link Demo")
                    .font(.headline)
            }
            .navigationTitle("Home")
            .navigationDestination(for: String.self) { id in
                Text("Detail for \(id)")
            }
        }
        .onOpenURL { url in
            handleDeepLink(url)
        }
    }

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

        switch host {
        case "product":
            if let id = components.queryItems?.first(where: { $0.name == "id" })?.value {
                navigationPath.append("product_\(id)")
            }
        case "settings":
            navigationPath.append("settings")
        default:
            break
        }
    }
}

Common Mistakes

  1. Using NavigationLink without NavigationStack: NavigationLink requires a NavigationStack ancestor. Without it, the link does nothing.

  2. Multiple navigationDestination for the same type: You can only have one navigationDestination(for:) per type per navigation stack.

  3. Modifying navigationPath from background threads: NavigationPath is not thread-safe. Always modify it from the main actor.

  4. Not providing unique tag values in TabView: Tags must be unique across all tabs for proper selection binding.

  5. Deep nesting of NavigationStack: Avoid nesting NavigationStack inside NavigationStack. This creates nested navigation bars and confusing behavior.

Practice Questions

  1. What is the difference between NavigationLink and navigationDestination?
  2. How does NavigationPath enable programmatic navigation?
  3. When would you use .sheet instead of pushing with NavigationLink?
  4. How do you pass data to a destination view using NavigationLink(value:)?
  5. Challenge: Build a recipe app with categories, recipe lists, and recipe details using NavigationStack. Add programmatic navigation that navigates to a specific recipe from a deep link (recipe-app://recipe/123). Include a "Back to Categories" button.

Mini Project

Create a StoreNavigationApp with:

  • A TabView with "Shop", "Cart", and "Profile" tabs, each with its own NavigationStack
  • Shop tab: list of product categories, each pushing to a product list, each product pushing to detail
  • Cart tab: list of cart items, "Checkout" button that pushes to a payment form
  • Profile tab: settings list with navigation to various settings screens
  • NavigationPath for the Shop tab to enable "Back to Home" from any depth
  • A .sheet for the checkout confirmation

FAQ

What is the difference between NavigationView and NavigationStack?

NavigationStack is the modern replacement for NavigationView, available from iOS 16. It provides better programmatic control with NavigationPath and supports value-based navigation.

Can I use NavigationStack on iOS 15?

No. NavigationStack requires iOS 16+. For iOS 15 support, use NavigationView with isActive or tag/selection-based navigation.

How do I customize the back button in NavigationStack?

Use .navigationBarBackButtonHidden(true) and add a custom toolbar item with a dismiss action.

How do I pass data back from a pushed view?

Use @Binding, closures, or unwind through a shared ObservableObject. SwiftUI does not have an automatic back-passing mechanism.

Can I have multiple NavigationStack in an app?

Yes. Each TabView tab typically has its own NavigationStack. You can also nest NavigationStacks in modally presented views.

What's Next

After mastering navigation, build data-driven lists with SwiftUI Lists, or add motion and transitions in SwiftUI Animations.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro