Deep links on iOS: a setup that stays maintainable
A maintainable deep-linking setup comes from one rule: treat links as app routes with typed parsing, ownership boundaries, and tests, not as random URL handling scattered across the codebase.
Deep links look simple right up until the app has more than five screens, three product surfaces, and a few legacy URLs nobody wants to touch.
Then the usual mess appears:
- URL parsing in
AppDelegate - navigation rules duplicated in coordinators and view models
- marketing links that bypass product rules
- push notifications constructing paths differently from universal links
- no clear answer to “what happens if the user is logged out?”
That is how deep linking turns into a small but permanent source of entropy.
The fix is not a giant routing framework.
The fix is to treat a deep link as one thing: an external representation of an internal route.
Once that boundary is explicit, the rest gets a lot less theatrical.
1. Start with routes, not raw URLs
A maintainable setup begins by deciding what your app can actually open.
Not every URL deserves first-class status. Your app has a finite set of meaningful destinations:
- home
- article details
- user profile
- settings
- paywall
- invite acceptance
- password reset
Model those destinations directly.
import Foundation
enum AppRoute: Equatable, Sendable {
case home
case article(id: String, referrer: String?)
case profile(userID: String)
case settings
case paywall(source: PaywallSource)
case invite(code: String)
case passwordReset(token: String)
}
enum PaywallSource: String, Equatable, Sendable {
case onboarding
case upsell
case settings
}
This sounds obvious, but teams skip it all the time. They keep URLs as loosely structured strings for too long, then wonder why every feature has its own parser and half the code is optional chaining.
If your navigation cannot be expressed as a small route model, the problem is probably your navigation architecture, not deep links.
2. Parse once, at the edge
URL parsing is edge work.
It should happen in one place, close to the entry points:
- universal links
- custom schemes
- push notification payloads
- widgets
- Siri/App Intents handoff
The parser’s only job is to convert untrusted external input into a typed AppRoute.
It should not:
- decide whether the user is allowed to see the screen
- directly push view controllers
- fetch data
- mutate app state unrelated to routing
That separation removes a lot of accidental complexity.
struct RouteParser {
func parse(url: URL) -> AppRoute? {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return nil
}
let path = components.path.split(separator: "/").map(String.init)
let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) })
switch (components.host, path) {
case ("articles", [let id]):
return .article(id: id, referrer: query["ref"] ?? nil)
case ("profile", [let userID]):
return .profile(userID: userID)
case ("settings", _):
return .settings
case ("paywall", _):
let source = PaywallSource(rawValue: query["source"] ?? "") ?? .upsell
return .paywall(source: source)
case ("invite", [let code]):
return .invite(code: code)
case ("reset-password", [let token]):
return .passwordReset(token: token)
default:
return nil
}
}
}
A few practical notes:
- Be strict about accepted shapes.
- Default only where product has agreed on fallback behavior.
- Reject links you do not understand instead of guessing.
Guessing is how you accidentally open the wrong screen for a malformed campaign URL and then spend a week pretending the analytics drop is mysterious.
3. Add a resolver for app state, not just URL shape
Parsing tells you what the link wants.
Resolving tells you what the app can do right now.
That distinction matters because deep links rarely operate in a vacuum. Real apps have prerequisites:
- authentication
- onboarding completion
- selected workspace/account
- feature-flag exposure
- stale or missing local data
If you mix parsing and resolution together, your parser becomes a haunted house of session checks and side effects.
Keep a second stage instead.
enum ResolvedRoute: Equatable, Sendable {
case open(AppRoute)
case requireLogin(pending: AppRoute)
case requireWorkspaceSelection(pending: AppRoute)
case unsupported
}
struct RouteResolver {
let session: UserSession
let featureFlags: FeatureFlagSnapshot
func resolve(_ route: AppRoute) -> ResolvedRoute {
switch route {
case .profile, .settings:
guard session.isLoggedIn else {
return .requireLogin(pending: route)
}
return .open(route)
case .invite:
guard session.isLoggedIn else {
return .requireLogin(pending: route)
}
return .open(route)
case .paywall(let source):
guard featureFlags.paywallEnabled else {
return .unsupported
}
return .open(.paywall(source: source))
default:
return .open(route)
}
}
}
This gives you a clean chain:
- external input →
AppRoute - app state + product rules →
ResolvedRoute - navigation layer executes the result
That architecture is much easier to test than one giant “openURL” handler that tries to do everything badly.
4. Give one system ownership of navigation
Deep-linking failures are often ownership failures.
The parser knows too much. The app delegate navigates directly. The tab coordinator also rewrites the route. Then a feature module adds one more fallback because deadlines are undefeated.
Pick one navigation owner.
Depending on the app, that may be:
- a root coordinator
- a navigation store/reducer
- a scene-level router
- a single
NavigationPathowner in SwiftUI
What matters is not the pattern name. What matters is this rule:
Only one layer should turn a resolved route into concrete navigation state.
For example:
@MainActor
final class AppRouter: ObservableObject {
@Published private(set) var selectedTab: Tab = .home
@Published var modal: Modal?
@Published var path = NavigationPath()
func open(_ route: AppRoute) {
switch route {
case .home:
selectedTab = .home
path = NavigationPath()
case .article(let id, _):
selectedTab = .home
path = NavigationPath()
path.append(HomeDestination.article(id: id))
case .profile(let userID):
selectedTab = .account
path = NavigationPath()
path.append(AccountDestination.profile(userID: userID))
case .settings:
selectedTab = .account
modal = .settings
case .paywall(let source):
modal = .paywall(source: source)
case .invite(let code):
selectedTab = .account
path = NavigationPath()
path.append(AccountDestination.invite(code: code))
case .passwordReset(let token):
modal = .passwordReset(token: token)
}
}
}
This is deliberately boring.
That is a compliment.
Deep-link infrastructure should be boring because it sits in the blast radius of marketing, product, lifecycle events, push notifications, and every “quick one-line change” that arrives on a Thursday afternoon.
5. Unify every entry point behind the same pipeline
One of the easiest ways to create bugs is to let each input source do its own thing.
Typical anti-pattern:
- universal links parse URLs one way
- push notifications build routes manually
- widgets open custom schemes with slightly different parameters
- QA test links against one path, but campaigns send another
Now “open article 123” is four implementations wearing a trench coat.
Prefer a single intake pipeline.
protocol RouteHandling: Sendable {
@MainActor
func handle(_ input: RouteInput)
}
enum RouteInput: Sendable {
case url(URL)
case pushNotification(userInfo: [AnyHashable: Any])
case widget(name: String, payload: [String: String])
}
Then normalize everything into a route as early as possible.
For example:
- push payload contains
deep_link - widget payload maps to a synthetic URL or directly to
AppRoute - app intent hands off an
AppRoute
The important bit is that resolution and navigation stay shared.
If your app behaves differently depending on whether the user tapped a URL or a push notification, that should be a conscious product decision, not an implementation accident.
6. Queue pending routes instead of improvising lifecycle hacks
A lot of broken deep-linking comes from timing:
- app launches cold
- auth state is still restoring
- root UI is not ready
- a modal is already presented
- a scene is active but not fully hydrated
Teams often “solve” this by adding DispatchQueue.main.asyncAfter and praying with unusual sincerity.
Do not do that.
Use a small pending-route queue with explicit readiness rules.
@MainActor
final class PendingRouteStore: ObservableObject {
private(set) var pending: AppRoute?
func set(_ route: AppRoute) {
pending = route
}
func consumeIfReady(isReady: Bool) -> AppRoute? {
guard isReady, let pending else { return nil }
self.pending = nil
return pending
}
}
This lets you say, clearly:
- parse immediately
- resolve what you can
- if login or app boot is incomplete, store the pending route
- resume once the app reaches a known-ready state
That is much safer than burying navigation side effects inside lifecycle callbacks and hoping the order never changes.
7. Make failure behavior explicit
A deep link system is not done when happy paths work.
It is done when failure behavior is intentional.
Decide what happens for:
- unknown routes
- deleted content
- links to features behind flags
- links requiring auth after session expiry
- links from old app versions or old campaign formats
A reasonable set of defaults is:
- unknown route → open home or show a lightweight “link not supported” state
- missing content → open the relevant container screen with a toast or inline error
- auth required → login, then continue
- feature unavailable → fallback screen, not silent failure
What you want to avoid is “nothing happened.”
Users interpret that as broken product. They are correct.
8. Track deep-link outcomes, not just opens
Most teams instrument link taps and stop there.
That is barely useful.
The better question is whether the user reached the intended destination.
Track at least these events:
- link received
- parse succeeded / failed
- resolution result
- final route opened
- continuation after login succeeded / failed
That gives you answers to questions like:
- Are campaign links malformed?
- Are users bouncing because they hit auth walls?
- Did a recent route refactor break one family of URLs?
A lightweight event model is enough.
struct DeepLinkEvent: Sendable {
enum Stage: String, Sendable {
case received
case parsed
case parseFailed
case resolved
case blockedByLogin
case opened
case unsupported
}
let stage: Stage
let rawURL: String?
let route: String?
}
You do not need an observability platform worthy of a satellite launch. You do need enough signal to debug reality.
9. Test the contracts that actually matter
Deep-link bugs are perfect test material because the contracts are crisp.
You can test:
- URL → route parsing
- route resolution under different session states
- continuation after login
- navigation side effects for key routes
Examples worth writing:
import Testing
@testable import App
struct RouteParserTests {
@Test
func parsesArticleLink() throws {
let url = try #require(URL(string: "https://vburojevic.dev/articles/123?ref=twitter"))
let route = RouteParser().parse(url: url)
#expect(route == .article(id: "123", referrer: "twitter"))
}
@Test
func rejectsUnknownLink() throws {
let url = try #require(URL(string: "myapp://unknown/path"))
let route = RouteParser().parse(url: url)
#expect(route == nil)
}
}
And for resolution:
struct RouteResolverTests {
@Test
func profileRequiresLoginWhenLoggedOut() {
let resolver = RouteResolver(
session: .loggedOut,
featureFlags: .defaults
)
let result = resolver.resolve(.profile(userID: "u_123"))
#expect(result == .requireLogin(pending: .profile(userID: "u_123")))
}
}
These tests are cheap, fast, and disproportionately useful.
If you do not test deep links at all, you are trusting one of the most cross-cutting parts of the app to remain correct through vibes alone. Brave, but not in a good way.
10. Keep the URL surface stable, even if navigation evolves
Internal navigation changes more often than public link surfaces should.
That means your external URL contract deserves some care:
- avoid leaking temporary screen structure into URLs
- prefer domain concepts over UI implementation details
- version only when you genuinely must
- maintain aliases for old links during migrations
Bad URL:
/tab/2/sheet/profile-modal?id=123
Better URL:
/profile/123
The app can change from tabs to split view to coordinator-based navigation. The user-facing concept should not need to care.
This is why route modeling is useful: it decouples public intent from navigation mechanics.
A practical architecture that stays small
If I were setting this up from scratch in a product codebase, I would keep it to five pieces:
AppRoute: typed route modelRouteParser: URL/payload → routeRouteResolver: route + app state → allowed actionAppRouter: executes navigationPendingRouteStore: holds deferred routes during login/boot
That is enough for most apps.
You do not need a routing framework that thinks it is a constitution.
You need clear ownership and a few rules:
- parse once
- resolve separately
- navigate in one place
- queue when the app is not ready
- test the contracts
Deep links stop being fragile when they stop being magical.
That is usually the whole game.