Building a Type-Safe Routing System for SwiftUI

Table of Contents
Introduction #
SwiftUI’s navigation system has come a long way since its introduction. We now have powerful tools like NavigationStack
, NavigationPath
, and the .navigationDestination
modifier that provide programmatic navigation capabilities. However, as applications grow in complexity, managing navigation state across multiple screens can become challenging.
I’ve been working on a routing system that addresses these challenges. It provides a declarative, type-safe approach to navigation that scales from simple push-and-pop scenarios to complex multi-modal flows with deep linking support.
The Problem with Traditional Navigation #
Before diving into the solution, let’s examine the typical challenges with SwiftUI navigation. While modern SwiftUI navigation is powerful, it can become difficult to maintain as complexity grows:
1// Traditional approach with NavigationStack and .navigationDestination 2struct ContentView: View { 3 @State private var showingSettings = false 4 @State private var showingProfile = false 5 @State private var selectedUser: User? 6 @State private var navigationPath = NavigationPath() 7 8 var body: some View { 9 NavigationStack(path: $navigationPath) {10 HomeView()11 .navigationDestination(for: ProfileDestination.self) { destination in12 ProfileView(userId: destination.userId)13 }14 .navigationDestination(for: SettingsDestination.self) { _ in15 SettingsView()16 }17 }18 .sheet(isPresented: $showingSettings) {19 SettingsView()20 }21 .sheet(isPresented: $showingProfile) {22 if let user = selectedUser {23 ProfileView(user: user)24 }25 }26 }27}28 29// Where do I put this? In the same file? A separate file?30struct ProfileDestination: Hashable {31 let userId: String32}33 34struct SettingsDestination: Hashable {}
The .navigationDestination
modifier is incredibly powerful, but it raises several questions: Where should these destination types be defined? How do we handle navigation from deeply nested views? What about combining programmatic navigation with deep linking? This approach quickly becomes unwieldy as you add more screens, and it’s difficult to handle programmatic navigation, deep linking, or state restoration consistently.
The Routing Architecture #
The routing system I’ve built consists of four main components:
Routable
- A protocol that defines a type that can be resolved into a destination viewRouter
- A property wrapper that manages navigation state and provides navigation methodsAppRoute
- A user-defined enum that conforms toRoutable
and defines all possible destinationsView.withRouter()
- A view modifier for injecting the router into the SwiftUI environment
Let’s explore each component in detail.
Defining Routes #
The foundation of type-safe routing is the Routable
protocol:
1public protocol Routable: Hashable, Identifiable {2 associatedtype Destination: View3 @ViewBuilder var destination: Destination { get }4}
This protocol ensures that every route can be uniquely identified and resolved to a SwiftUI view. Here’s how you define your app’s routes:
1enum AppRoute: Routable { 2 case home 3 case profile(userId: String) 4 case settings 5 case about 6 7 var id: String { 8 switch self { 9 case .home: return "home"10 case .profile(let userId): return "profile-\(userId)"11 case .settings: return "settings"12 case .about: return "about"13 }14 }15 16 @ViewBuilder17 var destination: some View {18 switch self {19 case .home:20 HomeView()21 case .profile(let userId):22 ProfileView(userId: userId)23 case .settings:24 SettingsView()25 case .about:26 AboutView()27 }28 }29}
By using an enum with associated values, we get compile-time safety and can pass data between screens naturally. No more optional bindings or state management headaches!
The Router Property Wrapper #
The Router
is implemented as a property wrapper that manages navigation state:
1@propertyWrapper 2public struct Router<Destination: Routable>: DynamicProperty { 3 @State private var core = RouterCore<Destination>() 4 5 public var wrappedValue: [Destination] { 6 get { core.path } 7 nonmutating set { core.path = newValue } 8 } 9 10 // Navigation methods11 public func navigate(to destination: Destination)12 public func goBack()13 public func popToRoot()14 public func present(_ destination: Destination, style: PresentationStyle = .sheet)15}
The router maintains a navigation path (array of destinations) and provides methods for common navigation operations. Internally, it uses SwiftUI’s @Observable
macro for efficient state updates.
Setting Up the Router #
To use the router in your app, you need to create an environment entry:
1extension EnvironmentValues {2 @Entry var router: Router<AppRoute> = Router()3}
Then apply the router to your root view:
1struct ContentView: View {2 var body: some View {3 HomeView()4 .withRouter(\.router)5 }6}
The withRouter
modifier injects the router into the SwiftUI environment and sets up the necessary navigation infrastructure.
Navigation in Action #
Once set up, navigation becomes very simple:
1struct HomeView: View { 2 @Environment(\.router) private var router 3 4 var body: some View { 5 VStack(spacing: 20) { 6 Button("View Profile") { 7 router.navigate(to: .profile(userId: "123")) 8 } 9 10 Button("Settings") {11 router.present(.settings)12 }13 14 Button("About (Full Screen)") {15 router.present(.about, style: .fullScreenCover)16 }17 }18 .navigationTitle("Home")19 }20}
Demo #
Here’s a routing demo straight from the Example app:
Advanced Features #
The routing system goes beyond basic navigation to support advanced features that are essential for production applications.
Deep Linking Support #
One of the most powerful features is built-in deep linking support. To enable deep linking, you first need to register your custom URL scheme in your app’s Info.plist
file (see Apple’s documentation for details):
1<key>CFBundleURLTypes</key> 2<array> 3 <dict> 4 <key>CFBundleURLName</key> 5 <string>com.yourapp.deeplink</string> 6 <key>CFBundleURLSchemes</key> 7 <array> 8 <string>myapp</string> 9 </array>10 </dict>11</array>
Then implement the DeepLinkHandler
protocol:
1struct MyDeepLinkHandler: DeepLinkHandler { 2 func handle(_ url: URL) -> [AppRoute]? { 3 guard url.scheme == "myapp" else { return nil } 4 5 switch url.host { 6 case "profile": 7 if let userId = url.pathComponents.last { 8 return [.profile(userId: userId)] 9 }10 case "settings":11 return [.settings]12 case "about":13 return [.home, .about]14 default:15 break16 }17 18 return nil19 }20}
Then enable deep linking in your router configuration:
1struct ContentView: View {2 var body: some View {3 HomeView()4 .withRouter(\.router, features: [5 .deepLinking(MyDeepLinkHandler())6 ])7 }8}
The router automatically handles the SwiftUI onOpenURL
modifier and converts URLs into navigation actions. You can even return multiple routes to build complex navigation flows!
Here’s deep linking in action straight from the Example app:
Universal Links #
The system also supports universal links (HTTPS URLs) alongside custom URL schemes. This is particularly useful for web-to-app transitions. To enable universal links, you need to configure associated domains in your app’s entitlements and host an apple-app-site-association
file on your server (see Apple’s Universal Links documentation for setup details).
1struct UniversalLinkHandler: DeepLinkHandler { 2 func handle(_ url: URL) -> [AppRoute]? { 3 guard url.scheme == "https", 4 url.host == "myapp.com" else { return nil } 5 6 // Parse path components and return routes 7 let pathComponents = url.pathComponents 8 switch pathComponents.first { 9 case "/profile":10 // Handle /profile/{ID} URLs11 if pathComponents.count > 1 {12 let userId = pathComponents[1]13 return [.profile(userId: userId)]14 }15 return [.profile(userId: "default")]16 case "/settings":17 return [.settings]18 default:19 return nil20 }21 }22}23 24// Enable both custom schemes and universal links25.withRouter(\.router, features: [26 .deepLinking(27 MyDeepLinkHandler(),28 includeUniversalLinks: true,29 universalLinkHandler: UniversalLinkHandler() // Optional: separate handler for universal links30 )31])
The universalLinkHandler
parameter is optional. If not provided, the same handler will be used for both custom URL schemes and universal links. Under the hood, the router uses SwiftUI’s onContinueUserActivity
modifier to handle universal links.
State Restoration #
For a polished user experience, the router supports automatic state restoration across app launches. Simply make your routes conform to Codable
:
1enum AppRoute: Routable, Codable {2 case home, profile(userId: String), settings3 // ... implementation4}
Then enable state restoration:
1.withRouter(\.router, features: [2 .stateRestoration(key: "main_navigation")3])
The router uses SwiftUI’s SceneStorage
to persist the navigation path and automatically restores it when the app relaunches.
Multiple Independent Routers #
For complex apps with multiple navigation flows (think TabView
with independent navigation stacks), you can create separate routers:
1extension EnvironmentValues { 2 @Entry var homeRouter: Router<HomeRoute> = Router() 3 @Entry var searchRouter: Router<SearchRoute> = Router() 4} 5 6TabView { 7 HomeView() 8 .withRouter(\.homeRouter) 9 .tabItem { Label("Home", systemImage: "house") }10 11 SearchView()12 .withRouter(\.searchRouter)13 .tabItem { Label("Search", systemImage: "magnifyingglass") }14}
Each router maintains its own navigation state, ensuring complete isolation between features.
Conclusion #
I’ve been using the routing system I’ve shared here for a few internal projects and in my opinion it provides a solid foundation for SwiftUI navigation and is much easier to use than the traditional approach. Whether you’re building a simple app or a complex multi-modal experience, type-safe routing can significantly improve your development workflow and user experience.
You can find the complete source code, including the demo project, on GitHub. The package requires iOS 17+ and macOS 14+.
Feel free to reach out if you have any questions or feedback!