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.