Skip to main content

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:

  1. Parse the link into a destination (plain values — which tab, which nested screen).
  2. Store that destination in an ObservableObject your views observe.
  3. 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, resolvedDestination is enough. Real apps usually need a tab plus a nested stack (Products → detail → reviews), and binding NavigationStack(path:) to a published array gives you exactly that — including deep, cold-start targets.


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.


Your handler receives this parsed object. For routing, prefer pathSegments (normalized, cross-platform) or resolvedDestination (a ready-made single string).

PropertyTypeDescription
pathSegments[String]Normalized route segments — use this to route.
parameters[String: String]All query parameters (?id=…&source=…).
urlURLThe original incoming URL.
schemeStringURL scheme (yourapp, https).
destinationStringRaw destination/path extracted from the URL.
resolvedDestinationStringEffective destination — from resolved campaign data when available, else destination.
campaignKeyNameString?Campaign key extracted from the link, if any.
jidString?Journey ID for attribution.
campaignIdString?Campaign id from ?campaign_id= / ?campaign=.
sourceString?Raw traffic source from ?source= / ?utm_source=.
authParamRequiredBooltrue when the link carries ?auth=required (see Module 6).
campaignDataPaylisherResolvedDeepLinkPayload?Full campaign object resolved from the backend (title, type, iosUrl, metaData, …), or nil.

These query parameters are parsed for you — you react to the parsed fields, never the raw query string:

ParameterAliasesEffect
keyNamekey, kCampaign key → resolved into campaignData.
jidJourney ID, attached to attribution.
sourceutm_sourceTraffic source, normalized into campaign_source.
campaign_idcampaignCampaign id.
authauth=required gates this one link behind login.

Example a campaign would send: yourapp://products/42?keyName=SUMMER25&source=push

Next: Module 6 — Auth-gated destinations