Opening the app from URLs
One of the most powerful features in iOS development is the ability to launch your app from a URL and navigate directly to specific content. Whether you're implementing referral links, handling social media shares, or allowing users to bookmark deep sections of your app, understanding deep links and universal links: best practices explained is essential for modern iOS development.
Today we'll walk through both approaches—from simple custom URL schemes to production-grade universal links—and show you how to integrate them seamlessly with SwiftUI's navigation system.
Custom URL schemes for simple cases
Custom URL schemes are the simplest way to get started. They work immediately after you register them, require no server setup, and are ideal for internal testing or single-platform scenarios. However, they lack security and have no verification mechanism, so reserve them for non-critical use cases.
To register a custom scheme, open your Xcode project, select your target, go to the Info tab, and add a URL scheme under "URL Types." Let's say you're building a ride-hailing app and want links like rideapp://ride/12345 to open a specific ride.
First, update your Info.plist by configuring the URL scheme:
// In Info.plist or via Xcode:
// URL Schemes: rideapp
// URL identifier: com.example.rideappNext, handle the URL in your SwiftUI app using the onOpenURL modifier at the top level:
@main
struct RideApp: App {
@State private var deepLinkPath: [String] = []
var body: some Scene {
WindowGroup {
NavigationStack(path: $deepLinkPath) {
ContentView()
.navigationDestination(for: String.self) { destination in
if destination.starts(with: "ride/") {
let rideID = destination.replacingOccurrences(of: "ride/", with: "")
RideDetailView(rideID: rideID)
}
}
}
.onOpenURL { url in
handleDeepLink(url: url)
}
}
}
private func handleDeepLink(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let host = components.host else {
return
}
deepLinkPath.append(host)
}
}The strength of custom schemes is their simplicity. The weakness is that any app can claim to handle rideapp:// URLs—there's no verification. For public links, you need universal links.
Universal links with apple-app-site-association
Universal links tie your app to a domain you own. When a user taps a link to that domain (e.g., https://ride.example.com/ride/12345), iOS checks your server for a cryptographically signed file called apple-app-site-association to confirm your app should handle it. This is the production-grade approach.
Create the apple-app-site-association file at the root of your web server (accessible via https://ride.example.com/.well-known/apple-app-site-association):
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.com.example.rideapp",
"paths": [
"/ride/*",
"/profile/*"
]
}
]
}
}Replace TEAM_ID with your Apple Developer Team ID. The paths array specifies which URL patterns your app claims. You can use wildcards; /ride/* matches any ride detail link.
In Xcode, enable "Associated Domains" under your target's Signing & Capabilities:
// Associated Domains capability:
// applinks:ride.example.com
// webcredentials:ride.example.com (optional, for password autofill)iOS downloads and caches this file, so changes propagate within hours. For testing, you can use Xcode's simulator to check your configuration without waiting.
The advantage: universal links are secure, verified, and users can share https:// URLs that work seamlessly whether the app is installed or not (falling back to web). The tradeoff: you need a domain you control and a properly configured server.
Handle with onOpenURL
Once the system recognizes your deep link (whether via custom scheme or universal link), onOpenURL fires with the URL. Parse it carefully to extract meaningful data and route the user accurately.
Here's a more robust handler for both custom schemes and universal links:
@main
struct RideApp: App {
@State private var navigationPath: [DeepLinkDestination] = []
var body: some Scene {
WindowGroup {
NavigationStack(path: $navigationPath) {
ContentView()
.navigationDestination(for: DeepLinkDestination.self) { destination in
switch destination {
case .ride(let rideID):
RideDetailView(rideID: rideID)
case .profile(let userID):
ProfileView(userID: userID)
case .settings:
SettingsView()
}
}
}
.onOpenURL { url in
handleDeepLink(url: url)
}
}
}
private func handleDeepLink(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
// For universal links: https://ride.example.com/ride/12345
if let host = components.host, host.contains("ride.example.com") {
let path = components.path
if path.starts(with: "/ride/") {
let rideID = path.replacingOccurrences(of: "/ride/", with: "")
navigationPath.append(.ride(rideID: rideID))
} else if path.starts(with: "/profile/") {
let userID = path.replacingOccurrences(of: "/profile/", with: "")
navigationPath.append(.profile(userID: userID))
}
}
// For custom schemes: rideapp://ride/12345
if url.scheme == "rideapp" {
let path = url.host ?? ""
if path.starts(with: "ride") {
let rideID = url.lastPathComponent
navigationPath.append(.ride(rideID: rideID))
}
}
}
}
enum DeepLinkDestination: Hashable {
case ride(rideID: String)
case profile(userID: String)
case settings
}Key points: always validate and sanitize the URL data before using it. Extract path components carefully. Normalize IDs (trim whitespace, validate format) to prevent crashes or injection attacks. Consider query parameters too—they're useful for tracking attribution or passing optional state.
If your app is already open and a deep link arrives, onOpenURL still fires, so you can update the navigation path dynamically. This is powerful for real-time features like notifications.
Route into NavigationStack paths
The true power emerges when you treat deep links as a first-class navigation concern, building them directly into your NavigationStack path state.
Define a route enum that mirrors your app's structure:
enum AppRoute: Hashable {
case home
case ride(id: String)
case profile(id: String)
case settings
case payment(method: String)
}
@main
struct RideApp: App {
@State private var navigationPath: [AppRoute] = []
var body: some Scene {
WindowGroup {
NavigationStack(path: $navigationPath) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
routeView(for: route)
}
}
.onOpenURL { url in
parseAndNavigate(url: url)
}
}
}
@ViewBuilder
private func routeView(for route: AppRoute) -> some View {
switch route {
case .home:
HomeView()
case .ride(let id):
RideDetailView(rideID: id)
case .profile(let id):
ProfileView(userID: id)
case .settings:
SettingsView()
case .payment(let method):
PaymentView(method: method)
}
}
private func parseAndNavigate(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
let pathSegments = components.path
.split(separator: "/")
.map(String.init)
guard let firstSegment = pathSegments.first else {
return
}
switch firstSegment {
case "ride":
if let rideID = pathSegments.dropFirst().first {
navigationPath.append(.ride(id: rideID))
}
case "profile":
if let userID = pathSegments.dropFirst().first {
navigationPath.append(.profile(id: userID))
}
case "payment":
if let method = URLComponents(url: url, resolvingAgainstBaseURL: true)?
.queryItems?.first(where: { $0.name == "method" })?.value {
navigationPath.append(.payment(method: method))
}
default:
break
}
}
}This approach is clean, testable, and scales. You can unit-test your parsing logic separately from your UI. It also handles the "app already open" scenario naturally—appending to the navigation path updates the UI immediately.
Common mistakes
Not handling the cold start case: If your app isn't running when the deep link is tapped, SwiftUI might not trigger onOpenURL in time during app initialization. Use scenePhase monitoring to catch URLs during launch if needed.
Forgetting to validate the apple-app-site-association file: Typos or incorrect JSON structure will silently fail. Test in Xcode's simulator using the "Open URL" simulation tool or use Apple's official AASA validation tool.
Mixing custom scheme and universal link logic poorly: They coexist, but conflicting handlers can cause confusion. Keep parsing logic separate and test both paths thoroughly.
Not encoding query parameters: If you're passing user-generated content in URLs, always percent-encode them. Use URLComponents instead of manual string concatenation.
Ignoring the navigation state: If your app is deep in the navigation stack and a deep link arrives, consider whether you should reset the stack to the root or push the new route. This depends on UX, but be intentional.
Key takeaways
- Use custom URL schemes for internal testing or simple single-platform scenarios; they're quick but unverified.
- Universal links require a domain and
apple-app-site-associationfile but are production-grade, secure, and best practice for public-facing URLs. - Always handle deep links in
onOpenURL, parse URLs defensively, and validate all extracted data. - Treat deep links as navigation state: integrate them into your
NavigationStackpath to keep routing logic unified and testable. - Test both cold-start (app not running) and warm-start (app already open) scenarios to ensure consistent behavior.
- Monitor your
apple-app-site-associationfile closely; iOS caches it, so propagation takes time in production.






Comments
Loading comments…