Module 5 — Routing the user to the right screen ⭐
This is the heart of the integration. The SDK parses the link and calls your handler; turning it into a screen is your app's job. You get a PaylisherDeepLink and decide which tab/screen it maps to.
The reliable pattern is three small steps:
- Parse the link into a destination (plain values — which tab, which nested screen).
- Store that destination in an
ObservableObjectyour views observe. - Render it — SwiftUI reacts to the published state.
Doing it this way means the same code handles cold start, foreground, and deferred links: the handler only updates state, and the UI applies whatever destination is current as soon as it's on screen.
1 & 2. A router that parses and publishes the destination
Keep navigation state in one ObservableObject. The SDK handler writes to it; your views observe it. Parsing is a pure function (parseTarget) you can reuse and unit-test.
import SwiftUI
import Paylisher
enum AppTab { case home, products, wallet, profile }
enum ProductRoute: Hashable { case detail(String), reviews(String) }
final class DeepLinkRouter: ObservableObject {
static let shared = DeepLinkRouter()
@Published var selectedTab: AppTab = .home
@Published var productsPath: [ProductRoute] = [] // nested stack inside Products
/// Called from the SDK handler. Writes published state; the UI applies it.
func navigate(_ deepLink: PaylisherDeepLink) {
guard let target = Self.parseTarget(deepLink) else { return }
DispatchQueue.main.async {
self.selectedTab = target.tab
if target.tab == .products { self.productsPath = target.productsPath }
}
}
// pathSegments is already normalized by the SDK — no URL re-parsing, identical on iOS & Android.
// yourapp://products/42/reviews and https://link.paylisher.com/products/42/reviews
// both arrive as ["products","42","reviews"].
static func parseTarget(_ deepLink: PaylisherDeepLink) -> NavTarget? {
let segs = deepLink.pathSegments
switch segs.first?.lowercased() {
case nil, "home":
return NavTarget(tab: .home)
case "products", "product":
if let id = segs.dropFirst().first ?? deepLink.parameters["id"] {
return segs.count >= 3 && segs[2].lowercased() == "reviews"
? NavTarget(tab: .products, productsPath: [.detail(id), .reviews(id)])
: NavTarget(tab: .products, productsPath: [.detail(id)])
}
return NavTarget(tab: .products) // products list
case "wallet": return NavTarget(tab: .wallet)
case "profile": return NavTarget(tab: .profile)
default: return NavTarget(tab: .home) // unknown link → safe default
}
}
}
struct NavTarget {
let tab: AppTab
var productsPath: [ProductRoute] = []
}
The path words (
home,products,wallet,profile) are yours — change them to match your screens. The only rule: the link you build in the dashboard must use the same words (the vocabulary contract).
Wire it in SDK setup, right after setup():
PaylisherSDK.shared.onDeepLink { deepLink, requiresAuth in
if !requiresAuth { DeepLinkRouter.shared.navigate(deepLink) } // auth-gated → see Module 6
}
3. Bind the router to your views
TabView binds to selectedTab; the nested NavigationStack binds to productsPath:
struct RootView: View {
@StateObject private var router = DeepLinkRouter.shared
var body: some View {
TabView(selection: $router.selectedTab) {
HomeView().tag(AppTab.home)
NavigationStack(path: $router.productsPath) {
ProductsListView()
.navigationDestination(for: ProductRoute.self) { route in
switch route {
case .detail(let id): ProductDetailView(id: id)
case .reviews(let id): ProductReviewsView(id: id)
}
}
}
.tag(AppTab.products)
WalletView().tag(AppTab.wallet)
ProfileView().tag(AppTab.profile)
}
}
}
Why parse the URL yourself instead of just using
resolvedDestination? For a single flat screen,resolvedDestinationis enough. Real apps usually need a tab plus a nested stack (Products → detail → reviews), and bindingNavigationStack(path:)to a published array gives you exactly that — including deep, cold-start targets.
Cold start (the link launched the app)
Because the handler is wired in setup() and only writes published state, a cold-start link sets the router before your first view appears. SwiftUI applies the current selectedTab / productsPath as the UI builds — the user lands directly on the target screen.
If your app starts on a login screen, don't navigate while logged out — the router state is already set, so just show the main UI after login and SwiftUI applies it:
struct ContentView: View {
@State private var isLoggedIn = false
var body: some View {
if isLoggedIn { RootView() } // router state applied as this appears
else { LoginView { isLoggedIn = true } }
}
}
This is the same flow the auth-gate uses — store the destination, apply it after login.
The PaylisherDeepLink object
Your handler receives this parsed object. For routing, prefer pathSegments (normalized, cross-platform) or resolvedDestination (a ready-made single string).
| Property | Type | Description |
|---|---|---|
pathSegments | [String] | Normalized route segments — use this to route. |
parameters | [String: String] | All query parameters (?id=…&source=…). |
url | URL | The original incoming URL. |
scheme | String | URL scheme (yourapp, https). |
destination | String | Raw destination/path extracted from the URL. |
resolvedDestination | String | Effective destination — from resolved campaign data when available, else destination. |
campaignKeyName | String? | Campaign key extracted from the link, if any. |
jid | String? | Journey ID for attribution. |
campaignId | String? | Campaign id from ?campaign_id= / ?campaign=. |
source | String? | Raw traffic source from ?source= / ?utm_source=. |
authParamRequired | Bool | true when the link carries ?auth=required (see Module 6). |
campaignData | PaylisherResolvedDeepLinkPayload? | Full campaign object resolved from the backend (title, type, iosUrl, metaData, …), or nil. |
What the link can carry
These query parameters are parsed for you — you react to the parsed fields, never the raw query string:
| Parameter | Aliases | Effect |
|---|---|---|
keyName | key, k | Campaign key → resolved into campaignData. |
jid | — | Journey ID, attached to attribution. |
source | utm_source | Traffic source, normalized into campaign_source. |
campaign_id | campaign | Campaign id. |
auth | — | auth=required gates this one link behind login. |
Example a campaign would send: yourapp://products/42?keyName=SUMMER25&source=push