Deep Linking in SwiftUI: External URLs to App Screens
Part 48 of 100 in the SwiftUI Zero to Expert Series
“The marketing email goes out tomorrow.
Make sure the product links work.” The product manager sent this at 4 PM on Thursday.
We had deep linking — I’d implemented it months ago.
I tested it: myapp://product/123.
Worked perfectly.
Ship it.
Friday morning, complaints flooded in.
Users clicking the email link saw nothing.
The app would open, flash briefly, and sit on the home screen.
The product page never appeared.
The bug took me three hours to find.
Our authentication flow required users to log in before viewing products.
Deep links arrived before authentication completed.
The router pushed the product screen, auth kicked the user to login, and the navigation state was lost.
The deep link worked — it just happened too fast.
I learned something that day: deep linking isn’t just about parsing URLs.
It’s about timing, state, authentication, and gracefully handling a world where links arrive at inconvenient moments.
Here’s how to build deep linking that actually works in production.
URL Scheme Deep Links
Setting Up URL Schemes
First, register your custom URL scheme in Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>myapp</string>
</array>
<key>CFBundleURLName</key>
<string>com.example.myapp</string>
</dict>
</array>This tells iOS: “When the user opens myapp://anything, launch this app."
Handling URL Schemes
The .onOpenURL modifier is your entry point:
import SwiftUI
@main
struct MyApp: App {
@State private var router = AppRouter()
var body: some Scene {
WindowGroup {
ContentView()
.environment(router)
.onOpenURL { url in
router.handleDeepLink(url)
}
}
}
}
@Observable
class AppRouter {
var path = NavigationPath()
var selectedTab = 0
func handleDeepLink(_ url: URL) {
guard url.scheme == "myapp" else { return }
// Parse URL: myapp://product/123
guard let host = url.host else { return }
switch host {
case "product":
if let productId = url.pathComponents.dropFirst().first {
navigateToProduct(id: productId)
}
case "category":
if let categoryId = url.pathComponents.dropFirst().first {
navigateToCategory(id: categoryId)
}
case "cart":
navigateToCart()
case "profile":
navigateToProfile()
default:
break
}
}
func navigateToProduct(id: String) {
selectedTab = 0
path.removeLast(path.count) // Clear existing navigation
path.append(Route.product(id: id))
}
func navigateToCategory(id: String) {
selectedTab = 0
path.removeLast(path.count)
path.append(Route.category(id: id))
}
func navigateToCart() {
selectedTab = 2
}
func navigateToProfile() {
selectedTab = 3
}
}
enum Route: Hashable {
case product(id: String)
case category(id: String)
case order(id: String)
}The key insight: always reset navigation state before applying the deep link.
If the user is five screens deep and taps a link, they expect to land on the linked content — not the linked content pushed onto their existing stack.
Universal Links
URL schemes have a problem: any app can register the same scheme. myapp:// could be hijacked.
Universal links solve this with domain verification.
Associated Domains Setup
Add to your app’s entitlements:
applinks:example.comThen host an apple-app-site-association file at your domain root:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.example.myapp",
"paths": [
"/product/*",
"/category/*",
"/order/*"
]
}
]
}
}The file must be served over HTTPS with application/json content type.
Apple validates this when the app is installed.
Handling Universal Links
Universal links arrive through the same .onOpenURL modifier:
import SwiftUI
@Observable
class UniversalLinkRouter {
var path = NavigationPath()
func handleUniversalLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }
let pathComponents = components.path.split(separator: "/").map(String.init)
guard pathComponents.count >= 2 else { return }
let type = pathComponents[0]
let id = pathComponents[1]
switch type {
case "product":
path.append(Route.product(id: id))
case "category":
path.append(Route.category(id: id))
case "order":
path.append(Route.order(id: id))
default:
break
}
}
}
@main
struct MyApp: App {
@State private var router = UniversalLinkRouter()
var body: some Scene {
WindowGroup {
ContentView()
.environment(router)
.onOpenURL { url in
if url.scheme == "https" {
router.handleUniversalLink(url)
} else {
// Handle custom scheme
}
}
}
}
}Universal links are more trustworthy because Apple verifies domain ownership.
They’re also better for sharing — users without your app see a real webpage instead of an error.
Deep Link Parser
Parsing gets messy with multiple URL formats.
Extract it into a dedicated parser:
import SwiftUI
struct DeepLink {
let route: Route
let parameters: [String: String]
enum Route {
case home
case product(id: String)
case category(id: String)
case search(query: String)
case order(id: String)
case profile
case settings
case unknown
}
}
class DeepLinkParser {
func parse(_ url: URL) -> DeepLink? {
let isUniversalLink = url.scheme == "https" || url.scheme == "http"
if isUniversalLink {
return parseUniversalLink(url)
} else {
return parseCustomScheme(url)
}
}
private func parseCustomScheme(_ url: URL) -> DeepLink? {
guard let host = url.host else { return nil }
let pathComponents = url.pathComponents.filter { $0 != "/" }
let queryParams = parseQueryParameters(url)
switch host {
case "home":
return DeepLink(route: .home, parameters: [:])
case "product":
guard let id = pathComponents.first else { return nil }
return DeepLink(route: .product(id: id), parameters: queryParams)
case "category":
guard let id = pathComponents.first else { return nil }
return DeepLink(route: .category(id: id), parameters: queryParams)
case "search":
let query = queryParams["q"] ?? ""
return DeepLink(route: .search(query: query), parameters: queryParams)
case "order":
guard let id = pathComponents.first else { return nil }
return DeepLink(route: .order(id: id), parameters: queryParams)
case "profile":
return DeepLink(route: .profile, parameters: [:])
case "settings":
return DeepLink(route: .settings, parameters: [:])
default:
return DeepLink(route: .unknown, parameters: [:])
}
}
private func parseUniversalLink(_ url: URL) -> DeepLink? {
let pathComponents = url.pathComponents.filter { $0 != "/" }
let queryParams = parseQueryParameters(url)
guard !pathComponents.isEmpty else {
return DeepLink(route: .home, parameters: [:])
}
let type = pathComponents[0]
let id = pathComponents.count > 1 ? pathComponents[1] : nil
switch type {
case "product":
guard let id = id else { return nil }
return DeepLink(route: .product(id: id), parameters: queryParams)
case "category":
guard let id = id else { return nil }
return DeepLink(route: .category(id: id), parameters: queryParams)
case "order":
guard let id = id else { return nil }
return DeepLink(route: .order(id: id), parameters: queryParams)
case "search":
let query = queryParams["q"] ?? ""
return DeepLink(route: .search(query: query), parameters: queryParams)
default:
return DeepLink(route: .unknown, parameters: [:])
}
}
private func parseQueryParameters(_ url: URL) -> [String: String] {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let queryItems = components.queryItems else { return [:] }
return Dictionary(uniqueKeysWithValues: queryItems.compactMap { item in
guard let value = item.value else { return nil }
return (item.name, value)
})
}
}The parser handles both URL schemes and universal links with the same interface.
Your router doesn’t care where the link came from.
Router with Deep Link Support
This is where I fixed the authentication bug — deferred deep links:
import SwiftUI
@Observable
class DeepLinkRouter {
var path = NavigationPath()
var selectedTab = 0
var pendingDeepLink: DeepLink?
private let parser = DeepLinkParser()
func handleURL(_ url: URL) {
guard let deepLink = parser.parse(url) else { return }
navigate(to: deepLink)
}
func navigate(to deepLink: DeepLink) {
// Reset navigation
path.removeLast(path.count)
switch deepLink.route {
case .home:
selectedTab = 0
case .product(let id):
selectedTab = 0
path.append(AppRoute.product(id: id))
case .category(let id):
selectedTab = 0
path.append(AppRoute.category(id: id))
case .search(let query):
selectedTab = 1
// Set search query via environment or published property
case .order(let id):
selectedTab = 3 // Profile tab
path.append(AppRoute.order(id: id))
case .profile:
selectedTab = 3
case .settings:
selectedTab = 3
path.append(AppRoute.settings)
case .unknown:
break
}
}
// The fix for the authentication bug
func deferDeepLink(_ deepLink: DeepLink) {
pendingDeepLink = deepLink
}
func processPendingDeepLink() {
guard let deepLink = pendingDeepLink else { return }
pendingDeepLink = nil
navigate(to: deepLink)
}
}
enum AppRoute: Hashable {
case product(id: String)
case category(id: String)
case order(id: String)
case settings
}When a deep link arrives during login, defer it.
After authentication succeeds, process the pending link.
The user sees the login screen, enters credentials, and lands on the linked product — exactly what they expected.
import SwiftUI
struct LoginView: View {
@Environment(DeepLinkRouter.self) private var router
@State private var isLoggingIn = false
var body: some View {
VStack {
// Login UI
Button("Log In") {
Task {
await performLogin()
router.processPendingDeepLink() // Process after auth
}
}
}
}
}Push Notification Deep Links
Push notifications often include deep link data:
import SwiftUI
class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
weak var router: DeepLinkRouter?
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
if let urlString = userInfo["deep_link"] as? String,
let url = URL(string: urlString) {
router?.handleURL(url)
}
completionHandler()
}
}
@main
struct MyApp: App {
@State private var router = DeepLinkRouter()
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup {
ContentView()
.environment(router)
.onOpenURL { url in
router.handleURL(url)
}
.onAppear {
delegate.notificationHandler.router = router
}
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
let notificationHandler = NotificationHandler()
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UNUserNotificationCenter.current().delegate = notificationHandler
return true
}
}The notification payload might look like:
{
"aps": { "alert": "Your order shipped!" },
"deep_link": "myapp://order/12345"
}User taps the notification, the handler extracts the URL, and the router navigates to the order.
Cold Start vs. Warm Start
Deep links behave differently depending on app state:
import SwiftUI
@Observable
class DeepLinkRouter {
var pendingDeepLink: DeepLink?
var isAppReady = false
func handleURL(_ url: URL) {
guard let deepLink = parser.parse(url) else { return }
if isAppReady {
navigate(to: deepLink)
} else {
// App is still launching, defer the link
pendingDeepLink = deepLink
}
}
func appDidBecomeReady() {
isAppReady = true
processPendingDeepLink()
}
}
// In your root view
struct RootView: View {
@Environment(DeepLinkRouter.self) private var router
@State private var hasInitialized = false
var body: some View {
Group {
if hasInitialized {
MainContentView()
} else {
SplashScreen()
}
}
.task {
await initialize()
hasInitialized = true
router.appDidBecomeReady()
}
}
}On cold start, the app might need to load user data, check authentication, or perform other setup.
Deep links wait for this initialization to complete.
Testing Deep Links
Test from the terminal — this saved me countless debug sessions:
# Test custom URL scheme
xcrun simctl openurl booted "myapp://product/123"
# Test with query parameters
xcrun simctl openurl booted "myapp://search?q=iphone"
# Test universal link
xcrun simctl openurl booted "https://example.com/product/123"Unit test the parser:
import SwiftUI
final class DeepLinkTests: XCTestCase {
var parser: DeepLinkParser!
override func setUp() {
parser = DeepLinkParser()
}
func testProductDeepLink() {
let url = URL(string: "myapp://product/123")!
let deepLink = parser.parse(url)
XCTAssertNotNil(deepLink)
if case .product(let id) = deepLink?.route {
XCTAssertEqual(id, "123")
} else {
XCTFail("Expected product route")
}
}
func testSearchWithQuery() {
let url = URL(string: "myapp://search?q=iphone")!
let deepLink = parser.parse(url)
if case .search(let query) = deepLink?.route {
XCTAssertEqual(query, "iphone")
} else {
XCTFail("Expected search route")
}
}
func testUniversalLink() {
let url = URL(string: "https://example.com/product/456")!
let deepLink = parser.parse(url)
if case .product(let id) = deepLink?.route {
XCTAssertEqual(id, "456")
} else {
XCTFail("Expected product route")
}
}
func testInvalidURL() {
let url = URL(string: "myapp://")!
let deepLink = parser.parse(url)
XCTAssertNil(deepLink)
}
func testQueryParameters() {
let url = URL(string: "myapp://product/123?ref=email&campaign=summer")!
let deepLink = parser.parse(url)
XCTAssertEqual(deepLink?.parameters["ref"], "email")
XCTAssertEqual(deepLink?.parameters["campaign"], "summer")
}
}Test every URL format your marketing team might use.
They will find creative ways to break your parser.
What I Know Now That I Wish I’d Known Then
Defer deep links during authentication. This was my production bug.
If your app requires login, save incoming deep links and process them after auth completes.
Users expect to land on the linked content, not the home screen.
Always reset navigation before navigating.
When a deep link arrives, the user might be anywhere in the app.
Clear the stack, switch tabs if needed, then push the destination. Don’t assume a clean state.
Test cold start separately from warm start. Links behave differently when the app is launching versus running.
The initialization timing matters.
Universal links require server coordination.
You can’t test universal links with just code changes. The server must host the association file, and it can take hours for Apple’s CDN to cache updates.
The Gotchas That Caught Me
URL encoding breaks links.
If a product name has special characters, your URL might be myapp://product/hello%20world. url.pathComponents gives you the decoded value, but url.absoluteString doesn't decode.
Be consistent about which you use.
Universal links don’t work in all contexts. Links pasted directly into Safari’s address bar don’t trigger universal link handling.
Links clicked from Notes, Mail, or Messages do. This causes confusion during testing.
onOpenURL doesn't fire for initial launch URL.
If the app is launched from a URL (cold start), you might need to handle it in the AppDelegate's application(_:open:options:) method on older iOS versions.
The first path component is often empty. url.pathComponents for https://example.com/product/123 returns ["/", "product", "123"]. Filter out the slash or your parsing breaks.
Deep links arrive before views are ready.
If your router modifies navigation state before the NavigationStack exists, nothing happens. Defer links until the view hierarchy is established.
Deep linking connects your app to the outside world:
- URL schemes for app-to-app communication
- Universal links for web-to-app continuity with domain verification
- Deferred deep links for handling authentication gracefully
- Push notifications for engagement-driven navigation
That marketing email panic taught me more than any documentation.
Deep links that work in development often fail in production because real users tap them at inconvenient times — during login, during initialization, five screens deep in a different flow.
Build for those inconvenient moments.
Defer when you can’t navigate immediately.
Reset state before navigating.
Test cold starts and warm starts separately.
The difference between “deep linking works” and “deep linking works reliably” is all in these edge cases.
In the next article, we’ll explore Tab-based Navigation — managing multiple navigation stacks within a tabbed interface.
Found this helpful? Share it!
Thank you for reading! If you enjoyed it, please consider clapping 👏 and following for more updates! 🚀
