What is VibeNavigation?
VibeNavigation is a route-based, type-safe modular navigation framework for iOS. Every screen in the app is named by a small value type — a route — and features navigate to each other’s screens by constructing those values, never by importing each other’s view controllers. Deep links fall out for free: every registered route is automatically URL-addressable, with zero hand-written URL parsing. It’s open-sourced at github.com/DerNoah/swift-vibe-navigation (iOS 16+, Swift 6, MIT).
This article walks through the problem it solves, the architecture, and each building block — routes, screens, navigation, guards, transitions, and tabs — the way I’d introduce it to a feature team.
The problem: features that import each other
Modular iOS codebases usually start clean: one Swift package per feature, a slim app target on top. The first crack appears the day the Home feature needs to open a Profile screen. The obvious move — import ProfileFeature and instantiate the view controller — quietly couples the two packages. A few sprints later Profile needs to open a Post from Home, and now the two packages depend on each other, which SwiftPM flatly refuses to build.
The usual escape hatches all have costs. A central Destination enum in a shared package decouples the features from each other but couples every feature to the enum — each new screen touches a file every team owns, and every switch grows another arm. Stringly-typed URL routers avoid the shared enum but give up the compiler: a typo in "profile/\(id)" becomes a runtime mystery. And in both designs, deep linking is a separate, second implementation of the same navigation logic that drifts out of sync with the first.
The insight behind VibeNavigation is that a navigation target is an interface, and interfaces belong in interface packages. What Home actually needs from Profile is not the profile view controller — it’s a name for “the profile screen of user X” that the compiler can check.
The architecture: interface vs. implementation packages
Every feature is split into two packages:
<Feature>-API-UI(module<Feature>UI) — the interface: a single file of route declarations. Nothing else. It depends only onVibeNavigationCore, which is Foundation-only.<Feature>-Internal(module<Feature>Internal) — the implementation: screens, coordinators, logic. It depends on the runtime, its own interface, and the interfaces of features it navigates to.
The invariant that keeps the graph cycle-free: interface packages never depend on implementations, and implementations never depend on other implementations. Home navigating to Profile means HomeInternal imports ProfileUI — a leaf package of value types. No cycle is possible, and SwiftPM’s rejection of package cycles acts as the enforcement backstop: if someone tries to sneak in an implementation import, the build breaks before review does.
AuthUI ─┐
HomeUI ─┼─▶ VibeNavigationCore (route declarations only)
ProfileUI ─┘
AuthInternal ─▶ VibeNavigation + AuthUI + HomeUI
HomeInternal ─▶ VibeNavigation + HomeUI + ProfileUI
ProfileInternal ─▶ VibeNavigation + ProfileUI + HomeUI
App (composition root) ─▶ VibeNavigation + the three -Internal packages
The host app is the only place features are enumerated. Adding a feature means creating its two packages and adding one line to the app’s module list — no other team’s code is touched.
Installing
Add the package and pick the right product per target:
dependencies: [
.package(url: "https://github.com/DerNoah/swift-vibe-navigation", from: "1.0.0")
]
| Product | Who depends on it |
|---|---|
VibeNavigationCore |
Feature interface packages — UIKit-free route declarations |
VibeNavigation |
Feature implementation packages and the host app — the UIKit/SwiftUI runtime |
VibeNavigationDebug |
The host app — a DEBUG-only debug server (more on this at the end) |
RouteRegistrarPlugin |
Optionally applied to implementation targets for build-time screen checks |
The package is imported as import VibeNavigation / import VibeNavigationCore. Everything is Swift 6 language mode with strict concurrency; UI code is @MainActor.
Declaring a route
A route is a struct in the feature’s interface package, declared with the @Route macro:
import VibeNavigationCore
@Route("post/:id")
public struct PostRoute {
public let id: String
public var parent: (any Route)? { HomeRoute() }
}
The macro generates the entire Route conformance: the parsed URL pattern, an initializer that maps :placeholders onto same-named stored properties, a pathRepresentation for logging and tooling, and a memberwise initializer. The part that makes this safe rather than merely convenient is that the macro validates the pattern at compile time: a :id placeholder without a stored property named id is a compile error, not a runtime surprise. The pattern and its parameters can never drift apart.
Two optional declarations shape how a route enters:
parentanswers the question every deep link raises: what should Back go to? When someone opensmyapp://post/42cold, the framework builds the stack[Home, Post]from the parent chain — parents can carry values, soEditProfileRoute(userID:)can declareProfileRoute(userID: userID)as its parent.preferredPresentation(.pushby default, or.present,.fullScreenCover,.alert) lets the route itself decide whether it pushes, sheets, covers, or shows as an alert.
Stored properties that aren’t in the pattern become query parameters — make them Optional and myapp://reset-password?token=abc parses them for free.
Registering screens
Declaring a route names a screen; registering binds it to a view. Each feature ships a FeatureModule whose register(in:) does the wiring — one line per screen, SwiftUI or UIKit, chosen by the builder’s return type:
public struct HomeFeatureModule: FeatureModule {
public init() {}
public func register(in context: FeatureRegistrationContext) {
let coordinator = HomeCoordinator(navigator: context.navigator)
context.register(PostRoute.self) { // SwiftUI — auto-wrapped
PostDetailView(coordinator: coordinator, postID: $0.id)
}
context.register(NotificationsRoute.self) { _ in // UIKit
coordinator.makeNotificationsViewController()
}
}
}
A route with no registration is a runtime NavigationFailure — loud (an assertion) in debug builds, a logged no-op in release, so a bad link can crash your debug build by design but never a shipping app.
If you’d rather catch the forgotten registration at build time, apply the RouteRegistrarPlugin to the target and declare one RouteScreen next to each view instead of hand-writing register(in:):
struct PostDetailScreen: RouteScreen {
static func make(_ route: PostRoute, _ dependencies: HomeCoordinator) -> some View {
PostDetailView(coordinator: dependencies, postID: route.id)
}
}
The plugin generates register(in:) from the declarations and diffs them against the interface package’s @Routes — a route without a screen fails the build, naming the offender. Pick one style per feature; they can’t coexist since both define register(in:).
Navigating
The Navigator is the single navigation API — and the single entry point for external links:
navigator.push(PostRoute(id: "42")) // one screen onto the current stack
navigator.navigate(to: PostRoute(id: "42")) // contextual: rebuilds the parent stack [Home, Post]
navigator.present(SettingsRoute()) // modal sheet with its own navigation stack
navigator.presentAlert(SignOutConfirmRoute(userID: "me"))
navigator.setRoot(HomeRoute())
push and navigate(to:) divide the work: push always puts exactly one screen on the current stack, while navigate(to:) is for jumps — it dispatches on the route’s preferredPresentation and, for pushes, rebuilds the stack from the parent chain so a cross-feature jump always lands with a sensible back path.
Cross-feature navigation is the payoff of the architecture. Home opens a profile with nothing but the interface import:
import ProfileUI
navigator.navigate(to: ProfileRoute(userID: "user99"))
And because registration already told the framework everything about the route, deep links need no additional code. myapp://post/42 resolves through a trie-based matcher to the same navigate(to:) call; a multi-segment URL like myapp://home/profile/user99/post/42 resolves each segment and is the stack. Push notifications funnel through the same door with navigator.open(pushPayload:). One detail worth copying in any router: a URL that fails to resolve any segment is rejected wholesale — a broken link never strands the user on a half-built stack.
Guards, transitions, and tabs
Guards. Some screens shouldn’t always be reachable — a paywall, unsaved changes, “you may only edit your own profile”. Registration takes an async canNavigate: closure that runs before anything appears:
context.register(
EditProfileRoute.self,
canNavigate: { await coordinator.canEditProfile($0.userID) }
) {
EditProfileView(coordinator: coordinator, userID: $0.userID)
}
A single denial aborts the whole navigation — nothing is pushed, presented, or half-applied — and the framework guarantees a slow guard can’t apply its result after a newer navigation has superseded it.
Transitions. Registrations can attach a transition: — .slideUp, .fade, or .custom(push:pop:) with your own animators. The registered transition drives the push and the pop, including the interactive edge-swipe, so a screen that slides up also slides back down.
Tabs. Features register tabs the same way they register screens, with a TabSpec carrying a root route and a priority. The app starts as a single stack (think pre-auth), and the tab bar installs itself the first time navigation enters tab-owned content. A route’s owning tab is derived from its parent chain, so deep links select the right tab automatically. Five tabs maximum, highest priority wins the slots — a sixth tab is reported, not silently dropped.
Alerts. Even alerts are routes: declare preferredPresentation = .alert and either register AlertContent (title, message, buttons whose handlers receive the navigator) for a standard UIAlertController, or register an ordinary SwiftUI view to have it shown as a centered card over a dimmed backdrop. myapp://profile/me/signout opening a confirmation alert is a one-liner.
The Navigation Debug Tool
Everything above describes code, but the part of this project I reach for daily is the tooling it enables. Because features register their routes rather than hiding them in view controllers, the app knows its complete navigation surface at runtime — every route, pattern, parent, tab, and guard. In DEBUG builds, the VibeNavigationDebug module exposes that manifest over a loopback connection, and a companion macOS app consumes it.
The Navigation Debug Tool gives you three things:
- a searchable navigation graph of the whole app — every route and its parent chain, exportable as Mermaid or JSON for architecture docs;
- a link console that fires deep links straight into the connected simulator, so testing
myapp://profile/user99/editdoesn’t require Safari gymnastics; - a live trace of every navigation event as it happens — pushes, presentations, blocked guards (
event=navigationBlocked), tab selections.
Because it’s driven entirely by the manifest the running app sends, adding a feature requires zero tool changes — relaunch the app and the graph refreshes. The server code ships nowhere: every file in VibeNavigationDebug is wrapped in #if DEBUG, so Release builds compile an empty module.
The tool is part of the same repository and downloadable as a prebuilt app from the 1.0.0 release (macOS 14+; it’s ad-hoc signed, so right-click → Open on first launch). The framework, the demo app, and the tool all live at github.com/DerNoah/swift-vibe-navigation.
Tips
| Tip | Why |
|---|---|
Depend on other features’ -API-UI packages only — never -Internal |
This is the whole architecture; SwiftPM’s cycle rejection enforces it for you |
Use navigate(to:) for cross-feature jumps, push within a flow |
navigate(to:) rebuilds the parent stack, so the user always has a sensible Back |
Give every deep-linkable route a parent |
A cold deep link without a parent lands on a bare screen with nowhere to go back to |
Prefer the RouteRegistrarPlugin on new features |
A forgotten screen becomes a build error naming the route, instead of a runtime log |
Link VibeNavigationDebug unconditionally |
Every file is #if DEBUG — Release builds compile an empty module, so there’s nothing to strip |
Watch the live trace for event=navigationBlocked |
A guard returning false is silent by design; the trace is where “nothing happened” gets explained |
Using Tuist? Read TuistSetup.md first |
The registrar plugin needs a small amount of setup under Tuist’s generated projects |