This guide gets one production-shaped route running before you introduce any remote configuration or queueing. The goal is simple: a button and a Universal Link should open the same SwiftUI destination through one public URL.
- Deploy to iOS 17+, macOS 14+, tvOS 17+, or watchOS 10+.
- Choose an HTTPS domain your team controls, such as
example.combelow. - Add
URLRouterto the App target and every Feature Package that declares aRouteModule.
Do not add URLRouterPolicyProvider yet. It is optional and belongs in the App
shell only when you need remotely managed restrictions.
Start with a complete URL, not an internal View name:
https://example.com/articles/42?presentation=push&version=1
/articles/42identifies the destination.presentation=pushtells SwiftUI how to show it.version=1lets a future app support a new URL shape without guessing.
Use HTTPS and a trusted host. Put stable IDs in URLs, never tokens, passwords, phone numbers, or an entire JSON object. Once a link appears in a web page, email, or notification, treat it as a public API.
Build URLs with URLComponents in the owning Feature rather than copying
strings around the app:
import Foundation
enum ArticleLinks {
static func detail(id: String) -> URL {
var components = URLComponents()
components.scheme = "https"
components.host = "example.com"
components.path = "/articles/\(id)"
components.queryItems = [
URLQueryItem(name: "presentation", value: "push"),
URLQueryItem(name: "version", value: "1")
]
return components.url!
}
}The Feature owns both its URL grammar and its destination views. A resolver
returns nil when a URL belongs to another Feature.
import SwiftUI
import URLRouter
enum ArticleFeature {
static let module = RouteModule(
id: "articles",
resolve: { link in
switch link.pathComponents {
case ["articles"]:
return ModuleRoute(moduleID: "articles", routeID: "list")
case ["articles", "saved"]:
return ModuleRoute(moduleID: "articles", routeID: "saved")
case ["articles", let id] where !id.isEmpty:
return ModuleRoute(
moduleID: "articles",
routeID: "detail",
parameters: ["id": id]
)
default:
return nil
}
},
destination: { route in
switch route.routeID {
case "list":
return AnyView(ArticleListView())
case "saved":
return AnyView(SavedArticlesView())
case "detail":
guard let id = route.parameters["id"] else { return nil }
return AnyView(ArticleDetailView(articleID: id))
default:
return nil
}
}
)
}Put fixed paths such as /articles/saved before the general
/articles/:id case. Otherwise saved would be mistaken for an article ID.
The App shell is the only place that knows which Feature packages are linked. It registers modules, creates navigation state, and applies app-wide rules. It does not parse Feature paths or construct Feature views.
import SwiftUI
import URLRouter
@main
struct CompanyApp: App {
@State private var router = ModuleRouter()
private let registry = ModuleRouteRegistry(modules: [ArticleFeature.module])
var body: some Scene {
WindowGroup {
RouterHost(router: router) {
AppTabs(router: router)
} destination: { route in
registry.destination(for: route)
}
.moduleLinkRouting(
router: router,
registry: registry,
allowedHosts: ["example.com"],
policy: ModuleRoutePolicy(
acceptedContractVersions: ["1"],
allowsUnversionedLinks: false
)
)
}
}
}Install RouterHost and moduleLinkRouting once per scene. Each window should
have its own ModuleRouter; that keeps multi-window state isolated.
moduleLinkRouting supplies SwiftUI's standard openURL action. Child views
do not need a router reference:
struct ArticleRow: View {
let id: String
@Environment(\.openURL) private var openURL
var body: some View {
Button("Read") {
openURL(ArticleLinks.detail(id: id))
}
}
}After asynchronous work, return to the main actor before calling it:
Task {
let id = try await recommendationService.nextArticleID()
await MainActor.run {
openURL(ArticleLinks.detail(id: id))
}
}For a presentation=tab route, URLRouter updates router.selectedTab. Bind
your TabView selection to that value. Keep the tab routeID and the SwiftUI
tag equal, for example favorites.
struct AppTabs: View {
@Bindable var router: ModuleRouter
var body: some View {
TabView(selection: Binding(
get: { router.selectedTab?.routeID ?? "home" },
set: { router.selectedTab = ModuleRoute(moduleID: "navigation", routeID: $0) }
)) {
HomeView().tabItem { Label("Home", systemImage: "house") }.tag("home")
FavoritesView().tabItem { Label("Favorites", systemImage: "star") }.tag("favorites")
}
}
}- Add the Associated Domains capability to the App target.
- Add
applinks:example.com. - Serve
https://example.com/.well-known/apple-app-site-associationover HTTPS without redirects. - Declare only paths that the app actually supports.
{
"applinks": {
"details": [{
"appIDs": ["TEAM_ID.com.example.CompanyApp"],
"components": [{ "/": "/articles/*" }]
}]
}
}Test on a physical device. A valid domain association is an Apple platform requirement; it is separate from URLRouter's own URL validation.
- Read Architecture before publishing routes for multiple teams or packages.
- Read Production governance when product needs remote route restrictions, incident controls, telemetry, or concurrent-route handling.