SwiftUI Routes is a simple library that is minimal and flexible to register routes and navigate to them.
- π― Flexible Navigation: Works seamlessly with custom navigation frameworks or presenters, or Apple's NavigationStackand sheets
- π¨ Simple & Declarative: Centralized route registration with minimal boilerplate
- π Type-Safe Routing: Navigate using strings ("/album/123") or strongly-typed values (Album(id: "123"))
- π¦ Multi-Package Support: Register and share a single Routesinstance across SPM packages
- π Deep Linking Ready: Built-in URL parsing and route matching for deep link handling
- π Scalable Architecture: Clean separation of concerns that grows with your project
Start by creating a Routes.swift file and registering destinations. Registrations accept either a resource path (string) or a Routable value. Paths can be parameterized to include params (like id).
import SwiftUIRoutes
@MainActor
var routes: Routes {
    let routes = Routes()
    routes.register(path: "/album/:id") { route in
        if let id = route.param("id") {
            AlbumView(id: id)
        }
    }
    routes.register(type: Album.self) { album in
        AlbumDetailView(album: album)
    }
    return routes
}- π€οΈ Path registrations use URL-style patterns. The closure receives a Routeso you can pull out parameters or query items withroute.param(_:)orroute.params.
- π·οΈ Type registrations work with any Routable. Conforming types define how to turn a value into the resource path that should be presented.
struct Album: Routable {
    var id: String
    var route: Route { Route("/album/\(id)") }
}Explore Examples/MusicApp for a complete sample integrating SwiftUI Routes; open Examples/MusicApp/MusicApp.xcodeproj in Xcode to run it.
- iOS 17.0+ / macOS 15.0+
- Swift 6.0+
dependencies: [
    .package(url: "https://github.com/gabriel/swiftui-routes", from: "0.2.1")
]
.target(
    dependencies: [
        .product(name: "SwiftUIRoutes", package: "swiftui-routes"),
    ]
)Attach your routes to a NavigationStack by keeping a RoutePath binding. The modifier installs every registered destination and exposes the binding through EnvironmentValues.routePath. Define routesDestination on the root view.
struct AppScene: View {
    @State private var path = RoutePath()
    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .routesDestination(routes: routes, path: $path)
        }
    }    
}Views can access the routePath from the environment (view hierarchy) and can push routes directly or use the provided view modifiers.
struct HomeView: View {
    @Environment(\.routePath) private var path
    var body: some View {
        VStack {
            Button("Album (123)") {
                path.push("/album/123")
            }
            Button("Featured Album") {
                path.push(Album(id: "featured"))
            }
            Text("Tap to open Latest")
                .push(Album(id: "123"), style: .tap)
        }
    }
}The push(_:style:) modifier wraps any view in a navigation trigger while still using the same registrations.
Handle deep links by converting incoming URLs to routes and pushing them onto the navigation path. Use onOpenURL(perform:) and create a Route from the URL:
struct AppScene: App {
    @State private var path = RoutePath()
    @State private var sheet: Routable?
    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $path) {
                HomeView()
                    .routesDestination(routes: routes, path: $path)
            }
            .routeSheet(routes: routes, item: $sheet)
            .onOpenURL(perform: handleDeepLink(_:))
        }
    }
    private func handleDeepLink(_ url: URL) {
        let route = Route(url: url)
        // Check for sheet presentation parameter
        if route.param("presentation") == "sheet" {
            sheet = route
            return
        }
        // Push onto the navigation stack
        sheet = nil
        path.push(route)
    }
}The Route(url:) initializer extracts the path and query parameters from the URL, matching them against your registered patterns:
- π΅ myapp://album/123β matches/album/:idwithid=123
- π myapp://album/123?presentation=sheetβ same route but presented as a sheet via thepresentationparameter
- π myapp://album/featured?lang=enβ matches/album/:idwithid=featuredand query paramlang=en
Use Routes.view(_:) to render a destination directly from a registered path or type, if you don't want to use NavigationStack or have a custom setup.
struct MyRouteViews: View {
    var body: some View {
        VStack(spacing: 16) {
            routes.view("/album/123")
            routes.view(Album(id: "featured"))
        }
    }
}Define a sheet binding and use routeSheet. If stacked is true, it will wrap the route view in another NavigationStack in case those views also push or pop.
struct HomeView: View {
    @State private var sheet: Routable?
    
    let album: Album
    var body: some View {
        VStack {
            Button("Open Album") {
                sheet = album
            }
        }
        // If stacked is true will wrap in a new NavigationStack configured with these routes
        .routeSheet(routes: routes, item: $sheet, stacked: true) 
    }
}Share a single Routes instance across packages without creating cyclical dependencies by letting each package contribute its own registrations. The app owns the Routes instance and passes it to package-level helpers that fill in the routes it knows about. Create a public func register(routes: Routes) method in each package.
import PackageA
import PackageB
import SwiftUI
import SwiftUIRoutes
@MainActor
var routes: Routes {
    let routes = Routes()
    PackageA.register(routes: routes)
    PackageB.register(routes: routes)
    return routes
}In PackageC:
import SwiftUI
import SwiftUIRoutes
public struct ExampleView: View {
    let routes: Routes
    @State private var path = RoutePath()
    public var body: some View {
        NavigationStack(path: $path) {
            List {
                Button("A view in PackageA") {
                    path.push("/a/1", params: ["text": "Hello!"])
                }
                Button("A view in PackageB") {
                    path.push("/b/2", params: ["systemName": "heart"])
                }
            }
            .routesDestination(routes: routes, path: $path)
        }
    }
}Each package exposes a simple register(routes:) entry point so it never needs to import another packageβs views.
In PackageA:
import SwiftUI
import SwiftUIRoutes
public func register(routes: Routes) {
    routes.register(path: "/a/:id") { url in
        Text(url.params["text"])
    }
}In PackageB:
import SwiftUI
import SwiftUIRoutes
public func register(routes: Routes) {
    routes.register(path: "/b/:id") { url in
        Image(systemName: url.params["systemName"] ?? "heart.fill")
    }
}This keeps navigation declarative and avoids mutual dependencies between packages because the shared Routes instance lives in the root target while features register themselves.