✈️ NavigationStack + Deep Linking in Large SwiftUI Apps
Deep Linking and State Restoration Without the Turbulence
Wesley Matlock8 min read·2 hours ago
–
Share Press enter or click to view image in full size You’re mid-booking a flight from Denver to LAX because Metallica just announced a surprise set. You’ve picked your seat (2F, always), added priority boarding, and — poof — the app crashes. Relaunch. Home screen. No state. No mercy. And there’s that moment of disbelief — like the gate agent just ripped up your boarding pass for fun.
That burn is why structured navigation, deep linking, and state restoration aren’t “nice to have.” They’re the difference between a user finishing checkout… or bailing.
This post shows a real, drop-in pattern I use in large SwiftUI apps:
- A single
**NavigationStack**
anchored at the root - A type-safe routing enum (URL → Enum → View)
- Deep linking via custom schemes and universal links
- State restoration using a Codable stack
- A dev-only overlay toggled with a three-finger tap that prints the current route stack
- Notes for visionOS 2+ where behavior or entry points differ (because new if fun)
The demo app is airline-themed — FlightDeck Pro (demo).
🫶 Quick thing before we dive in: If this article helps you even a little, tapping that 👏 button (you can hit it up to 50 times!) helps it reach more iOS devs. It’s a small gesture that makes a big difference. Thanks! You can also find this post and all my other work at wesleymatlock.com
🗺 Architecture at a Glance
🧭 The Routing Shape: URL → Enum → View
Strings don’t scale. Enums do. We’ll model every “screen” as a FlightRoute
. The trick: make it **Hashable**
** + ****Codable**
, so it can live inside a NavigationStack(path:)
and be saved to disk.
import SwiftUI import Observation
enum FlightRoute: Hashable, Codable { case home case flights case flightDetail(id: UUID) case seatSelect(flightID: UUID) case checkout(flightID: UUID, seat: String) // Metallica easter eggs used in test links case setlist(city: String) // e.g., “LA-One” }Now that we’ve got our enum locked in, let’s give it a place to live — our single source of truth for all navigation.
@Observable
final class NavigationModel {
var stack: [FlightRoute] = []
func reset() { stack.removeAll() }
func push(_ route: FlightRoute) { stack.append(route) }
func pop() { if !stack.isEmpty { stack.removeLast() } }
func replace(with routes: [FlightRoute]) { stack = routes }
}
🏠 Root Setup: One Stack to Rule Them All
A single NavigationStack
at the root, bound to the model’s stack
. Every child view pushes FlightRoute
values, not views.
struct RootView: View { @Environment(NavigationModel.self) private var nav
var body: some View { @Bindable var nav = nav NavigationStack(path: $nav.stack) { HomeView() .navigationDestination(for: FlightRoute.self) { route in switch route { case .home: HomeView() case .flights: FlightListView() case .flightDetail(let id): FlightDetailView(flightID: id) case .seatSelect(let flightID): SeatSelectionView(flightID: flightID) case .checkout(let flightID, let seat): CheckoutView(flightID: flightID, seat: seat) case .setlist(let city): SetlistView(city: city) } } #if DEBUG .overlay(NavigationDebugOverlay().environment(nav)) #endif } .environment(nav) } }No view is allowed to instantiate another view directly for navigation. They push routes. The stack is testable, encodable, and you can log, inspect, or rewrite it while debugging.
🌐 Deep Linking: Custom Schemes + Universal Links
We’ll support both:
- Custom scheme like
flightdeck://flight/<UUID>
- Universal link like
https://flightdeck.example.com/seat/<flightUUID>/<seat>
Both get parsed into routes, then we decide whether to append or replace the current stack.
struct DeepLinkParser { static func routeChain(from url: URL) -> [FlightRoute]? { let comps = url.pathComponents.filter { $0 != “/” }
// Handle custom scheme URLs: flightdeck://flight/UUID
if url.scheme == "flightdeck" {
switch url.host {
case "flight":
if comps.count == 1,
let id = UUID(uuidString: comps[0]) {
return [.home, .flights, .flightDetail(id: id)]
}
case "seat":
if comps.count >= 2,
let id = UUID(uuidString: comps[0]) {
let seat = comps[1]
return [.home, .flights, .flightDetail(id: id), .seatSelect(flightID: id), .checkout(flightID: id, seat: seat)]
}
case "setlist":
if comps.count == 1 {
return [.home, .setlist(city: comps[0])]
}
default:
break
}
}
// Handle web URLs: https://flightdeck.example.com/flight/UUID
else if url.scheme == "https" {
if comps.first == "flight",
comps.count == 2,
let id = UUID(uuidString: comps[1]) {
return [.home, .flights, .flightDetail(id: id)]
}
if comps.first == "seat",
comps.count >= 3,
let id = UUID(uuidString: comps[1]) {
let seat = comps[2]
return [.home, .flights, .flightDetail(id: id), .seatSelect(flightID: id), .checkout(flightID: id, seat: seat)]
}
if comps.first == "setlist", comps.count == 2 {
return [.home, .setlist(city: comps[1])]
}
}
return nil
} }Cool — we can push and pop. But if the URL drops us straight into a seat selection, we want to skip the tap-fest and just land there.
🖥️ Testing Deep Links in the Simulator from the Command Line
You don’t need to tap links in Safari or Notes to test — you can fire them straight into the iOS or visionOS simulator using the xcrun simctl openurl
command.
General Syntax:
xcrun simctl openurl booted “
- You can swap
booted
for a specific device UDID if you’ve got multiple running. Example: Custom Scheme Deep Links:
Open a flight detail screen
xcrun simctl openurl booted “flightdeck://flight/123E4567-E89B-12D3-A456-426614174000”
Jump straight to seat 2F checkout
xcrun simctl openurl booted “flightdeck://seat/123E4567-E89B-12D3-A456-426614174000/2F”
Metallica setlist view
xcrun simctl openurl booted “flightdeck://setlist/LosAngeles”Example: Universal Links:
xcrun simctl openurl booted “https://flightdeck.example.com/flight/123E4567-E89B-12D3-A456-426614174000” xcrun simctl openurl booted “https://flightdeck.example.com/seat/123E4567-E89B-12D3-A456-426614174000/2F”💡 Pro Tips:
- Works on both iOS and visionOS simulators.
- You can create a small shell script with your most-used deep links for quick testing.
- If nothing happens, double-check your app’s
Info.plist
forCFBundleURLSchemes
and Associated Domains setup.
🖥️ Testing Deep Links from the Command Line (Simulator)
You don’t have to tap links in Safari or Notes. Fire them straight into the active simulator with simctl
.
General syntax
xcrun simctl openurl booted “
Open a flight detail screen
xcrun simctl openurl booted “flightdeck://flight/123E4567-E89B-12D3-A456-426614174000”
Jump straight to seat 2F checkout
xcrun simctl openurl booted “flightdeck://seat/123E4567-E89B-12D3-A456-426614174000/2F”
Metallica setlist view
xcrun simctl openurl booted “flightdeck://setlist/LosAngeles”Universal link examples
xcrun simctl openurl booted “https://flightdeck.example.com/flight/123E4567-E89B-12D3-A456-426614174000” xcrun simctl openurl booted “https://flightdeck.example.com/seat/123E4567-E89B-12D3-A456-426614174000/2F”Pro tips
- Works on both iOS and visionOS simulators.
- If nothing happens, check your
CFBundleURLSchemes
and Associated Domains. For universal links on device, you’ll need a validapplinks:
entry. - Keep a small shell script of your most-used test links so QA and teammates can reproduce states instantly.
🥽 visionOS Considerations
While the navigation model and deep link parsing logic are identical between iOS and visionOS, this section is also me experimenting with visionOS for the first time — and honestly, it’s been a blast to tinker with something new and see what’s possible. There are some key differences to keep in mind:
1. Multiple Windows
- visionOS supports multiple active
WindowGroup
scenes. Decide which window should handle incoming deep links. You might route them to your primary window or open a new one.
2. Immersive Spaces
- If part of your app is presented in an immersive space, you cannot push SwiftUI
NavigationStack
routes directly into it. Instead, deep links should trigger a change in your root window UI that in turn updates or launches the immersive space.
3. Focus and Interaction
- Navigation gestures in visionOS rely on gaze + pinch or hardware input, so triple-tap debug overlays are best tested in Simulator or on-device with a trackpad/keyboard.
Example Multi-Window Setup:
#if os(visionOS)
@main
struct FlightDeckDemoApp: App {
@State private var nav = NavigationModel()
var body: some Scene {
WindowGroup(id: “main”) {
RootView()
.environment(nav)
}
WindowGroup(id: “map”) {
MapView()
.environment(nav)
}
}
}
#endifFor most cases, keeping a shared NavigationModel
across all windows will make state restoration and deep linking consistent.
💾 State Restoration: Save the Stack, Not the Views
If a user is halfway through checkout and your app updates from TestFlight, you want them back where they were. This is that safety net.
struct RoutePersistence {
private let key = "navStack.v1"
func save(_ routes: [FlightRoute]) {
do {
let data = try JSONEncoder().encode(routes)
UserDefaults.standard.set(data, forKey: key)
} catch {
print("⚠️ Failed to encode routes: \(error)")
}
}
func restore() -> [FlightRoute]? {
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
do {
return try JSONDecoder().decode([FlightRoute].self, from: data)
} catch {
print("⚠️ Failed to decode routes: \(error)")
return nil
}
}
}
💾 State Restoration “Safe Mode”
To avoid crashes when restoring outdated or invalid routes, redirect gracefully:
func restoreSafe() -> [FlightRoute] {
guard let routes = restore() else { return [.home] }
return routes.allSatisfy(isValidRoute) ? routes : [.home]
}
private func isValidRoute(_ route: FlightRoute) -> Bool {
switch route {
case .flightDetail(let id): return flightExists(id)
case .seatSelect(let id): return flightExists(id)
case .checkout(let id, _): return flightExists(id)
default: return true
}
}
🧪 QA / Debug Tips
- Use
simctl openurl
alongside the triple-tap overlay to confirm routing. - Reset state with
.reset()
or by clearing the UserDefaults key. - For Handoff scenarios, consider testing with
NSUserActivity
.
🧪 Dev-Only Debug Overlay (Three-Finger Tap)
Fire this up on device, triple-tap anywhere in the app, and watch the current route stack spill onto the screen like a ground crew unloading the luggage cart.
struct NavigationDebugOverlay: View {
@Environment(NavigationModel.self) private var nav
@State private var isVisible = false
var body: some View {
ZStack(alignment: .topTrailing) {
if isVisible {
VStack(alignment: .trailing, spacing: 8) {
Text("Navigation Stack")
.font(.caption.bold())
ForEach(Array(nav.stack.enumerated()), id: \.offset) { idx, route in
Text("\(idx). \(describe(route))")
.font(.system(size: 11).monospaced())
.padding(6)
.background(.black.opacity(0.6))
.cornerRadius(6)
}
}
.padding()
.transition(.opacity)
}
}
.contentShape(Rectangle())
.highPriorityGesture(
TapGesture(count: 3).onEnded { isVisible.toggle() }
)
}
private func describe(_ route: FlightRoute) -> String {
switch route {
case .home: return "home"
case .flights: return "flights"
case .flightDetail(let id): return "flightDetail(\(id.uuidString.prefix(6)))"
case .seatSelect(let id): return "seatSelect(\(id.uuidString.prefix(6)))"
case .checkout(let id, let seat): return "checkout(\(id.uuidString.prefix(6)), seat:\(seat))"
case .setlist(let city): return "setlist(\(city))"
}
}
}
🛑 Gotchas That Bite in Bigger Apps
- Mixing multiple stacks: Keep one
NavigationStack
per window. - Pushing views directly: Don’t. Push routes.
- Enum drift: When routes evolve, old saved stacks might fail to decode. Version your storage key and migrate.
- Universal link timing: On cold launch, the activity may arrive before views are ready.
- visionOS windows: Decide which window should consume a deep link — or open a new one for it.
- Immersive limitations: You can’t deep link directly into immersive content; trigger it from your window UI.
- Preview weirdness: Triple-tap gestures can be flaky in previews.
- Recoverability: Redirect to
.home
if a deep link references missing data. - QA will find a way to get into a state you swore was impossible. That’s not a bug in QA — that’s their job.
✈️ Wrapping It Up
Navigation chaos is a morale killer. With this pattern, your routes are data, your links are type-safe, and your state comes back like it never left. You can handle deep links without fear, restore a user’s position across launches, and even debug the stack live with a triple-tap.
🎯 Bonus: More Real-World iOS Survival Stories
If you’re hungry for more tips, tricks, and a few battle-tested stories from the trenches of native mobile development, swing by my collection of articles: @wesleymatlock or wesleymatlock.com These posts are packed with real-world solutions, some laughs, and the kind of knowledge that’s saved me from a few late-night debugging sessions. Let’s keep building apps that rock — and if you’ve got questions or stories of your own, drop me a line. I’d love to hear from you.
Originally published on Medium