Programmatic Navigation & Deep Linking
Reviewed & published by Brayan K
Drive navigation from code, not just taps. Learn NavigationStack with a path binding, navigationDestination(for:), pushing and popping in code, type-safe routes with an enum, and deep linking with onOpenURL.
Part of the free Swift course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- • Use NavigationStack with a bound path
- • Map values to screens with navigationDestination(for:)
- • Push and pop programmatically by editing the path
- • Model type-safe routes with a Hashable enum
- • Parse and handle deep links with onOpenURL
- • See how this differs from a basic NavigationLink
1️⃣ Basic NavigationLink
A plain NavigationLink pushes a destination when the user taps it. That is fine for simple menus but gives you no control from code.
// Basic NavigationLink: user taps, SwiftUI pushes the destination.
import SwiftUI
struct MenuView: View {
var body: some View {
NavigationStack { // modern container (iOS 16+)
List {
NavigationLink("Settings") { SettingsView() }
NavigationLink("Profile") { ProfileView() }
}
.navigationTitle("Menu")
}
}
}
struct SettingsView: View { var body: some View { Text("Settings") } }
struct ProfileView: View { var body: some View { Text("Profile") } }2️⃣ Programmatic Push and Pop
Bind a path to the NavigationStack. Append values to push, empty it to pop to root — all from your own code, not taps.
// Bind a PATH so YOU control navigation in code, not just on tap.
import SwiftUI
struct RootView: View {
@State private var path: [Int] = [] // the navigation stack
var body: some View {
NavigationStack(path: $path) {
VStack(spacing: 12) {
Button("Go to page 3") {
path = [1, 2, 3] // push three screens at once
}
Button("Pop to root") {
path.removeAll() // empty path -> back to root
}
}
// Map an Int value to the view it should show.
.navigationDestination(for: Int.self) { page in
Text("Page \(page)")
.navigationTitle("Page \(page)")
}
}
}
}3️⃣ Type-Safe Routes with an Enum
A Hashable enum names every valid destination. A switch in navigationDestination(for:) forces you to handle each case.
// Type-safe routes: an enum lists every valid destination.
import SwiftUI
enum Route: Hashable { // Hashable so it can live in a path
case detail(id: Int)
case settings
}
struct AppView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
VStack {
Button("Open item 42") { path.append(.detail(id: 42)) }
Button("Open settings") { path.append(.settings) }
}
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let id): Text("Detail \(id)")
case .settings: Text("Settings")
}
}
}
}
}Your turn. Fill in the path binding and the method that pushes a value.
// 🎯 YOUR TURN — fill in each ___ then run it in Xcode.
import SwiftUI
struct DemoView: View {
@State private var path: [Int] = []
var body: some View {
NavigationStack(path: ___) { // 👉 the binding to the path
Button("Push 7") {
path.___(7) // 👉 add 7 to the stack
}
.navigationDestination(for: Int.self) { n in
Text("Number \(n)")
}
}
}
}
// ✅ Tapping the button pushes a screen showing "Number 7".4️⃣ Deep Linking with onOpenURL
onOpenURL fires when the app opens from a URL. Parse it into a route value and append it to the path to jump straight to the right screen.
// Deep linking: parse an incoming URL into a route and navigate.
import SwiftUI
struct ContentView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
Text("Home")
.navigationDestination(for: Route.self) { route in
if case .detail(let id) = route { Text("Detail \(id)") }
}
}
// Fired when the app opens via myapp://item/42
.onOpenURL { url in
// url.pathComponents -> ["/", "item", "42"]
if url.host == "item",
let id = Int(url.lastPathComponent) {
path.append(.detail(id: id)) // jump straight there
}
}
}
}Pro Tips
- 💡 Keep one source of truth. Store the path in your state and drive everything from it.
- 💡 Make routes a Hashable enum so the compiler checks every destination.
- 💡 Empty the path with removeAll() to pop straight to root.
- 💡 Reuse routing for deep links — parse the URL into the same route values.
Common Errors (and the fix)
- Nothing happens on append: you forgot a matching navigationDestination(for:).
- "does not conform to Hashable": make your route type Hashable.
- Still using NavigationView: switch to NavigationStack for path binding.
- Deep link ignored: attach onOpenURL near the root view.
📋 Quick Reference
| Task | Code | Effect |
|---|---|---|
| Container | NavigationStack(path: $path) | data-driven stack |
| Map value | navigationDestination(for:) | value to screen |
| Push | path.append(route) | show that screen |
| Pop to root | path.removeAll() | back to start |
| Value link | NavigationLink(value:) | push by value |
| Deep link | .onOpenURL { url in … } | handle URL |
Mini-Challenge: Type-Safe Routing
Build a Hashable route enum, bind it to a NavigationStack, and push a profile screen from a button.
// 🎯 MINI-CHALLENGE: type-safe routing
// 1. Define enum Screen: Hashable with cases .home and .profile(name: String).
// 2. Add @State private var path: [Screen] = [].
// 3. Use NavigationStack(path: $path) with navigationDestination(for: Screen.self).
// 4. A button should append .profile(name: "Ada") to push that screen.
//
// ✅ Tapping the button navigates to a profile screen for "Ada".
// your code here
Practice quiz
Which container replaced NavigationView for stack navigation in modern SwiftUI?
- NavigationStack
- NavigationView
- TabView
- ScrollView
Answer: NavigationStack. NavigationStack is the modern push/pop container introduced in iOS 16.
What does binding a path to a NavigationStack let you do?
- Add tabs
- Style the bar
- Drive navigation programmatically by editing the path array
- Disable navigation
Answer: Drive navigation programmatically by editing the path array. A path binding holds the navigation stack so you can push and pop by mutating the array.
Which modifier registers what view to show for a given value type?
- onAppear
- navigationDestination(for:)
- sheet(item:)
- navigationTitle
Answer: navigationDestination(for:). navigationDestination(for:) maps a value type to the destination view it should present.
To pop to the root of a NavigationStack programmatically, you...
- Call dismiss()
- Remove the modifier
- Restart the app
- Set the path to an empty collection
Answer: Set the path to an empty collection. Emptying the path collection pops every pushed view back to the root.
How do you push a new screen programmatically with a path binding?
- Append a value to the path
- Toggle a Bool
- Use a segue
- Call push()
Answer: Append a value to the path. Appending a value to the bound path pushes its matching navigationDestination view.
Why use an enum for type-safe routes?
- It avoids imports
- It enumerates all valid destinations the compiler can check
- It is required by SwiftUI
- It is faster
Answer: It enumerates all valid destinations the compiler can check. An enum lists every valid route so the compiler catches typos and missing cases.
Which modifier handles an incoming deep-link URL?
- onTapGesture
- onChange
- onReceive
- onOpenURL
Answer: onOpenURL. onOpenURL(perform:) fires when the app is opened via a URL, letting you parse and route.
After parsing a deep-link URL, how do you navigate to that screen?
- Show an alert
- Ignore it
- Append the parsed route value to the navigation path
- Reload the app
Answer: Append the parsed route value to the navigation path. You translate the URL into a route value and append it to the path to navigate there.
How does NavigationLink(value:) differ from the basic NavigationLink(destination:)?
- It opens a sheet
- It pushes a value resolved by navigationDestination instead of an inline view
- It does nothing
- It is deprecated
Answer: It pushes a value resolved by navigationDestination instead of an inline view. NavigationLink(value:) sends a value that navigationDestination(for:) turns into a screen.
For routes to drive a path and be diffable, the route type should be...
- Hashable
- A class
- Optional
- Sendable only
Answer: Hashable. Path values must be Hashable so the stack can store and compare them.
Continue this course
- Previous: Result Builders
- Next: Swift Macros — Freestanding and attached macros, what they expand to, and SwiftSyntax
Frequently asked questions
What is NavigationStack and how is it different from NavigationLink alone?
NavigationStack is the modern SwiftUI container (iOS 16 and later) that manages a stack of pushed screens, replacing the older NavigationView. A plain NavigationLink only reacts when the user taps it, pushing an inline destination. NavigationStack adds the ability to bind a path, so you can drive navigation from code, restore state, and represent the whole stack as data. NavigationLink still triggers pushes, but the stack is what gives you programmatic control.
How does the path binding enable programmatic navigation?
When you write NavigationStack(path: $path), the path is an array (or NavigationPath) that represents every screen currently pushed. Appending a value pushes its matching destination, and removing values pops screens. Setting the path to several values at once deep-jumps into a nested location, and emptying it pops back to the root. Because the stack is just data you own, you can navigate in response to buttons, timers, notifications, or deep links rather than only taps.
What does navigationDestination(for:) do?
navigationDestination(for:) registers a mapping from a value type to the view that should be shown for it. When a value of that type is appended to the path, or sent by a NavigationLink(value:), SwiftUI looks up this modifier and presents the returned view. This decouples the act of navigating, which just adds a value, from the definition of the destination, which lives in one place. It is the key piece that makes value-driven, type-safe navigation work.
Why use an enum for routes?
Modeling your destinations as a Hashable enum gives you type-safe, exhaustive routing. Each case is a valid screen, optionally carrying data such as an item id, and a switch over the enum in navigationDestination forces you to handle every case, so the compiler catches missing or misspelled routes. Appending an enum value to the path is self-documenting and refactor-friendly, which scales far better than passing around strings or loose booleans for navigation state.
How do I support deep links into a specific screen?
Attach the onOpenURL modifier to a view near the root. When the system launches or foregrounds your app via a URL such as myapp://item/42, the closure receives the URL. You parse its host, path components, or query items to figure out what the user wants, translate that into one of your route values, and append it to the navigation path. Because navigation is data-driven, that single append pushes the right screen, giving you reliable deep linking with the same routing code you already use.