Building Restorable Deep Links with NavigationStack
A deep link is often described as “open a screen when the app receives a URL.” A reliable implementation has more states to handle: the app may be launching cold, already showing a detail screen, waiting for authentication, or restoring a discarded scene. If each case performs navigation differently, users get duplicate screens and unpredictable back buttons.
NavigationStack gives us a better starting point because its path can be ordinary data. A list tap, an external URL, and restored scene state can all produce the same route values.
Model destinations, not views
Define the destinations the application actually supports. A route is Hashable for navigation and Codable for restoration:
enum Route: Hashable, Codable {
case library
case book(id: Int)
case settings
}
A typed [Route] is often preferable to an unconstrained NavigationPath when one feature owns all destinations. The compiler keeps the path homogeneous, and tests can compare complete navigation outcomes without decoding type-erased values.
struct RootView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
LibraryView(openBook: { id in
path.append(.book(id: id))
})
.navigationDestination(for: Route.self) { route in
switch route {
case .library:
LibraryView(openBook: { path.append(.book(id: $0)) })
case .book(let id):
BookView(bookID: id)
case .settings:
SettingsView()
}
}
}
}
}
The root screen usually does not need to be an element in the path. The array represents the destinations pushed after the root, so removing the final element matches the visible back navigation.
Use NavigationPath when a coordinator genuinely needs heterogeneous route types from several modules. Even then, each module should expose a small route value instead of asking the coordinator to store constructed views or closures.
Parse URLs outside the view
URLs are untrusted input. A parser should validate the scheme, destination, and identifier before returning domain routes. The view only decides what to do with a valid result.
func routes(for url: URL) -> [Route]? {
guard url.scheme == "reader" else { return nil }
switch url.host {
case "book":
guard let value = url.pathComponents.dropFirst().first,
let id = Int(value) else { return nil }
return [.book(id: id)]
case "settings":
return [.settings]
default:
return nil
}
}
An external deep link normally replaces the path so its result is independent of the screen the user happened to be viewing. In-app navigation can append instead.
.onOpenURL { url in
guard let destination = routes(for: url) else { return }
path = destination
}
For a hierarchy such as a collection followed by a book, the parser can return both route elements. That preserves a meaningful back destination instead of opening a detail screen with a surprising exit.
Authentication is another routing state, not a reason to scatter flags across destination views. Store the validated route as a pending intent, present authentication, and commit the path after authentication succeeds. Do not briefly push protected content and then pop it away.
Multiwindow applications should not put the navigation path in a process-wide singleton. Each scene owns its own stack and restoration payload. Domain data such as the signed-in account or library can be shared, but opening a book in one window should not rewrite the back stack in another. When the system delivers a URL to a scene, that scene’s coordinator parses and commits the route. If the product intentionally opens a new window, pass a validated domain identifier into the scene-creation flow instead of sharing a mutable path.
Universal links also need a safe fallback. A server route may be obsolete, or an older app may not recognize a new destination. Returning to a stable root with an understandable message is better than constructing half a path or showing an empty destination. Diagnostics may record the rejected route category, but should not log private query parameters, invitation tokens, or other secrets from the original URL.
Restore intent, not a stale interface
Because the route is codable, it can be stored per scene. Encoding as Base64 keeps the SceneStorage value simple:
func encode(_ path: [Route]) -> String? {
try? JSONEncoder().encode(path).base64EncodedString()
}
func decode(_ value: String) -> [Route]? {
guard let data = Data(base64Encoded: value) else { return nil }
return try? JSONDecoder().decode([Route].self, from: data)
}
Restoration must still validate the decoded intent. A referenced book may have been deleted, an account may have signed out, or a destination may no longer exist in a newer app version. Truncate an invalid path to the nearest valid parent or fall back to the root rather than forcing the old visual state back onto new data.
Treat the encoded route as a persistence format. Renaming cases or changing associated values can break old scene data. A versioned envelope gives larger applications room to migrate; smaller applications can intentionally discard undecodable paths.
Test the seams
Most navigation defects can be found without a full UI test. Use table-driven tests for valid URLs, wrong schemes, missing identifiers, nonnumeric identifiers, unknown destinations, and routes that require authentication. Test restoration with empty, valid, malformed, and outdated payloads. Keep a small number of UI tests for the back-stack behavior that matters to users.
Once URL syntax, domain routes, and SwiftUI rendering are separate, deep linking stops being a set of special-case pushes. It becomes a validated state transition that is understandable at launch, during normal use, and after restoration.