Building a Type-Safe Routing System for SwiftUI

Edoardo 🇮🇹 Jul 12, 2025 7 min read
In this post, we’ll explore how to build a comprehensive routing system for SwiftUI applications. We’ll cover type-safe navigation, modal presentation, deep linking, universal links and state restoration. The full source code is available on GitHub!

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 in
12 ProfileView(userId: destination.userId)
13 }
14 .navigationDestination(for: SettingsDestination.self) { _ in
15 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: String
32}
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:

  1. Routable - A protocol that defines a type that can be resolved into a destination view
  2. Router - A property wrapper that manages navigation state and provides navigation methods
  3. AppRoute - A user-defined enum that conforms to Routable and defines all possible destinations
  4. View.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: View
3 @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 @ViewBuilder
17 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 methods
11 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.

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:

The routing system demonstrating navigation, modal presentation, and programmatic navigation

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 break
16 }
17
18 return nil
19 }
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:

Deep linking demonstration

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} URLs
11 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 nil
20 }
21 }
22}
23 
24// Enable both custom schemes and universal links
25.withRouter(\.router, features: [
26 .deepLinking(
27 MyDeepLinkHandler(),
28 includeUniversalLinks: true,
29 universalLinkHandler: UniversalLinkHandler() // Optional: separate handler for universal links
30 )
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), settings
3 // ... implementation
4}

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.

State restoration demonstration - navigation stack is automatically restored when the app relaunches
Note that modal presentations are intentionally not restored, as users typically don’t expect sheets to reappear after relaunching an app.

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!