Deep links tend to break where app lifecycle and navigation meet. A user taps rsverse://settings?tab=privacy, the app wakes up, and two navigation stacks disagree about what should happen next. UIKit may still be restoring a tab controller while SwiftUI is already reacting to scene state.
If each view parses the URL for itself, routing rules start to drift. Validation gets duplicated, and a privacy-sensitive screen can open before the app has checked whether the user should see it. I send every URL through one router before any screen is pushed.
I split the job into five steps: accept the URL, parse it, hold it if the UI is not ready, present the route, and record the result. UIKit and SwiftUI handle the entry point and presentation. The route type between them does not depend on either UI framework.

Start with one small route type
The sample app handles one article route and one settings route. It accepts a custom scheme and a verified HTTPS origin, but rejects a lookalike host such as blog.rsverse.dev.attacker.example.
import Foundation
enum ArticleRoute: Equatable, Sendable {
case article(id: UUID)
case settings(tab: String?)
}
enum RouteParseError: Error {
case unsupportedOrigin
case malformedPath
}
struct RouteParser: Sendable {
func parse(_ url: URL) throws -> ArticleRoute {
guard let parts = URLComponents(
url: url,
resolvingAgainstBaseURL: false
) else {
throw RouteParseError.malformedPath
}
let path = parts.path.split(separator: "/").map(String.init)
switch (parts.scheme?.lowercased(), parts.host?.lowercased()) {
case ("rsverse", "article"):
guard path.count == 1,
let id = UUID(uuidString: path[0]) else {
throw RouteParseError.malformedPath
}
return .article(id: id)
case ("rsverse", "settings"):
let tab = parts.queryItems?.first { $0.name == "tab" }?.value
return .settings(tab: tab)
case ("https", "blog.rsverse.dev"):
guard path.count == 2,
path[0] == "article",
let id = UUID(uuidString: path[1]) else {
throw RouteParseError.malformedPath
}
return .article(id: id)
default:
throw RouteParseError.unsupportedOrigin
}
}
}URLComponents saves me from manually splitting query strings and percent-encoded values. I compare the scheme and host exactly. A prefix check could accept a lookalike domain such as blog.rsverse.dev.attacker.example. The parser either returns a typed route or throws a specific error; it never tries to reinterpret an almost-valid URL.
Hold the route until navigation is ready
On a cold start, the URL can arrive before the root coordinator, selected tab, restored session, or authentication state is ready. I put the route in a small inbox until navigation can handle it:
actor RouteInbox {
private let capacity = 8
private var pending: [ArticleRoute] = []
func enqueue(_ route: ArticleRoute) {
guard pending.last != route else { return }
if pending.count == capacity {
pending.removeFirst()
}
pending.append(route)
}
func drain() -> [ArticleRoute] {
defer { pending.removeAll(keepingCapacity: true) }
return pending
}
}I cap the inbox at eight routes so it cannot grow forever. I also ignore an immediate duplicate, which prevents one handoff from opening the same screen twice. A deliberate sequence such as article → settings → article still works. If the product should keep only the newest destination, I would change the queue policy rather than hide that decision in presentation code.
Send every entry point through the same parser
For a scene-based UIKit app, the initial URL is available in the connection options. Later URLs arrive through the scene delegate:
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
private let parser = RouteParser()
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let url = connectionOptions.urlContexts.first?.url else { return }
accept(url)
}
func scene(
_ scene: UIScene,
openURLContexts contexts: Set<UIOpenURLContext>
) {
contexts.forEach { accept($0.url) }
}
private func accept(_ url: URL) {
do {
let route = try parser.parse(url)
// Send to the inbox or the scene's navigation coordinator.
} catch {
// Record a privacy-safe diagnostic; do not navigate.
}
}
}A SwiftUI lifecycle can feed the same parser from onOpenURL:
WindowGroup {
RootView()
.onOpenURL { url in
Task { await routeReceiver.accept(url) }
}
}I keep the accepted origins and route grammar in one parser. UIKit and SwiftUI both send URLs through it, so they cannot quietly develop different rules or produce diagnostics that are hard to compare.
Let the coordinator decide what to show
The parser does not decide whether to show a login screen, select a tab, dismiss a sheet, or reuse the current detail view. I leave those choices to a main-actor navigation coordinator, which handles a route in this order:
- Parse and validate the URL.
- If app restoration is incomplete, enqueue the typed route.
- If authentication is required, retain the route and begin login.
- After the prerequisite succeeds, present the route once.
- Record the outcome without storing private URL parameters.
In an app with multiple windows, I route to the scene that received the context unless the product explicitly requires something else. A global navigator could open private content in a background or unrelated scene.
Test the URLs you do not want to accept
Beyond the four recorded cases, I would add coverage for uppercase schemes, percent encoding, missing identifiers, extra path components, duplicate query items, unsupported schemes, queue capacity, and adjacent duplicates. Parser tests do not need to import UIKit, so they can stay fast enough to run on every change.
func testRejectsLookalikeWebHost() throws {
let url = try XCTUnwrap(
URL(string: "https://blog.rsverse.dev.attacker.example/settings")
)
XCTAssertThrowsError(try RouteParser().parse(url))
}What I verified in the lab
On July 18, 2026, the standalone Deep Link Lab produced the following controlled results on the environment named at the top of this article:
- Four XCTest parser cases passed with no failures or skips.
- A valid article URL was accepted while the app was active.
- After terminating the app, the same article URL launched it and produced the expected typed route.
- A settings URL returned the app from the background and preserved the
privacyquery value. - A malformed article identifier opened the registered scheme but produced a visible “Rejected safely” result instead of navigation.
The sample and its evidence log are versioned with the article source. This run verifies custom-scheme handling in Simulator; it does not prove an Associated Domains configuration, a physical-device handoff, or routing across multiple active scenes.
What this sample does not cover
- Universal-link association files and domain entitlement setup.
- Handoff, Spotlight, notification actions, and web-to-app attribution.
- Product-specific authentication and authorization checks.
- State restoration across process termination.
I would add those features as adapters around the route type instead of letting the parser become a hidden app coordinator. For me, the router’s job is simple: every incoming URL reaches the same decision point before navigation changes. That keeps cold starts, foreground links, notification taps, UIKit, and SwiftUI on the same rules.
Before shipping
- Run parser tests on every supported Swift toolchain.
- Test cold, warm, and background launches on a physical device.
- Test signed-out, signing-in, and signed-in states.
- Open malformed and lookalike links; confirm no navigation occurs.
- Repeat with two active scenes if the app supports multiple windows.
- Inspect logs to ensure tokens, email addresses, and complete URLs are not recorded.
