Universal Links and Deep Linking on iOS
In the world of mobile applications, providing a seamless and integrated user experience is paramount. One powerful way to achieve this is through deep linking, which allows you to launch your app and navigate directly to specific content within it from external sources like websites, emails, or other apps. On iOS, Apple provides two primary mechanisms for deep linking: Custom URL Schemes and the more robust Universal Links.
While custom URL schemes offer a straightforward way to open your app, Universal Links are Apple's recommended approach, providing a superior user experience and enhanced security. In this article, we'll dive deep into both, with a strong focus on implementing Universal Links, and equip you with the knowledge to integrate them effectively into your iOS applications.
Understanding Deep Linking Concepts
Before diving into implementation, let's clarify the two main types of deep linking on iOS.
Custom URL Schemes
Custom URL schemes allow you to register a unique scheme for your app, like myapp://. When a user taps a link with this scheme (e.g., myapp://products?id=123), iOS attempts to open your app.
Here's how it generally works:
┌─────────────────┐ ┌───────────────────────┐ ┌───────────────────┐
│ Browser/Other App │ ──► │ iOS (myapp:// handler) │ ──► │ Your iOS App │
└─────────────────┘ └───────────────────────┘ └───────────────────┘
Pros: Simple to set up: Requires minimal configuration within your app's Info.plist. No server-side configuration: No need to manage files on your web server.
Cons: Poor user experience if app isn't installed: If the app isn't installed, the link will fail, often with an unhelpful "Safari cannot open the page" error. There's no graceful fallback to a website. Security risks: Multiple apps can register the same custom URL scheme, leading to "scheme squatting" where an attacker's app could intercept links meant for your app. iOS will typically open the first app installed that registered that scheme, which is unpredictable. * Limited context: It's harder to pass complex data and maintain state across app launches.
Universal Links
Introduced in iOS 9, Universal Links are standard HTTP/HTTPS links (e.g., https://yourdomain.com/products?id=123) that your web server is configured to handle for your app. When a user taps a Universal Link, iOS checks if your app is installed and configured to handle that specific domain. If so, your app launches directly to the specified content without going through Safari. If not, the link opens in Safari, providing a seamless fallback to your website.
Pros: Seamless user experience: Links open directly in your app if installed, or gracefully fall back to your website if not. Secure: Only your app can open links for your verified domain, preventing scheme squatting. Standard web links: They are regular web links, meaning they work everywhere (email, social media, etc.) and are discoverable by search engines. Better analytics: You can track clicks on your web server.
Cons: Requires server-side configuration: You need control over your web server to host a special configuration file. More complex setup: Involves both app-side and server-side setup.
Given the significant advantages, Universal Links are the recommended approach for deep linking on iOS.
Implementing Universal Links
Implementing Universal Links involves two main parts: server-side configuration and app-side configuration.
1. Server-Side Configuration: The apple-app-site-association File
The core of Universal Links lies in a file hosted on your web server called apple-app-site-association (AASA). This JSON file tells iOS which app IDs are associated with which paths on your domain.
a. Create the AASA File:
The AASA file must be a JSON file with a specific structure. Here's an example:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "YOUR_TEAM_ID.com.yourcompany.yourapp",
"paths": [
"/products/*",
"/articles/*",
"/promo/*",
"NOT /promo/restricted/*"
]
}
]
}
}
appID: This is your Apple Team ID followed by your app's Bundle Identifier. You can find your Team ID in your Apple Developer account (Membershipsection) or in Xcode under Project settings -> General -> Identity -> Team.paths: An array of strings specifying which paths on your domain your app can handle.- Use
*as a wildcard to match any substring. For example,/products/*matches/products/123,/products/new, etc. - Use
?as a wildcard for a single character. - Use
NOTprefix to exclude specific paths. This is crucial for security and preventing unintended app launches. apps: This array is deprecated and should be empty.
- Use
b. Host the AASA File:
The apple-app-site-association file must be hosted at one of two specific locations on your domain: https://yourdomain.com/apple-app-site-association https://yourdomain.com/.well-known/apple-app-site-association
Crucial Hosting Requirements: HTTPS Only: The file must be served over HTTPS. No Redirects: The file must be directly accessible without any HTTP redirects. MIME Type: The server must serve the file with the application/json or text/plain MIME type. No File Extension: The file name must be exactly apple-app-site-association without any .json extension.
You can verify your AASA file setup using Apple's App Search API Validation Tool.
2. App-Side Configuration
a. Add Associated Domains Entitlement:
In Xcode, for your target: 1. Go to the Signing & Capabilities tab. 2. Click + Capability and add Associated Domains. 3. Under Associated Domains, add an entry for each domain that will host your Universal Links, prefixed with applinks:. Example: applinks:yourdomain.com If you have subdomains, you might add applinks:*.yourdomain.com or specific subdomains like applinks:shop.yourdomain.com.
This tells iOS that your app is capable of handling links from yourdomain.com. When a user installs your app, iOS will fetch the AASA file from your associated domain to confirm the association.
b. Handle Universal Links in Your App:
When your app is launched via a Universal Link, iOS delivers the URL to your app. How you handle it depends on your app's lifecycle structure:
- For UIKit apps using
AppDelegate(older lifecycle):`swift func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let incomingURL = userActivity.webpageURL else { return false }
print("Incoming Universal Link: (incomingURL.absoluteString)") handleIncomingURL(incomingURL) return true } `
- For SwiftUI apps or UIKit apps using
SceneDelegate(modern lifecycle):`swift class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow?
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, let incomingURL = userActivity.webpageURL else { return }
print("Incoming Universal Link: (incomingURL.absoluteString)") handleIncomingURL(incomingURL) }
// ... other SceneDelegate methods } ` If you're using SwiftUI's App lifecycle, you can use the onOpenURL modifier: `swift @main struct MyApp: App { var body: some Scene { WindowGroup { ContentView() .onOpenURL { url in print("Incoming URL from onOpenURL: (url.absoluteString)") handleIncomingURL(url) } } }
// Your global URL handler, or pass it down to views func handleIncomingURL(_ url: URL) { // Logic to parse the URL and navigate // Example: if url.pathComponents.contains("products") { if let productID = url.queryParameters?["id"] { print("Navigating to product with ID: (productID)") // Trigger navigation in your app, e.g., via a Router or state change } } else if url.pathComponents.contains("articles") { if let articleSlug = url.lastPathComponent { print("Navigating to article with slug: (articleSlug)") } } } } `
3. Parsing the URL and Navigation
The handleIncomingURL function is where you'll implement your routing logic. You'll need to parse the URL object to extract paths and query parameters.
A handy extension for URL to get query parameters:
extension URL {
var queryParameters: [String: String]? {
guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems else { return nil }
var parameters = [String: String]()
for item in queryItems {
parameters[item.name] = item.value
}
return parameters
}
}
Now, your handleIncomingURL can use this:
func handleIncomingURL(_ url: URL) {
guard let host = url.host else { return }
// Example logic for a simple routing
switch host {
case "yourdomain.com":
if url.pathComponents.contains("products") {
if let productID = url.queryParameters?["id"] {
print("Navigating to product detail for ID: \(productID)")
// Your app's navigation logic here
// e.g., router.showProduct(id: productID)
}
} else if url.pathComponents.contains("articles") {
if let articleSlug = url.lastPathComponent, articleSlug != "articles" {
print("Navigating to article: \(articleSlug)")
// e.g., router.showArticle(slug: articleSlug)
}
} else {
print("Unhandled path on yourdomain.com: \(url.path)")
}
default:
print("Unhandled host: \(host)")
// Maybe open a generic home screen or log an unknown link
}
}
Testing Universal Links
Testing Universal Links can sometimes be tricky. Here are a few methods:
- Notes App: The simplest way. Type your Universal Link (e.g.,
https://yourdomain.com/products/123) into the Notes app. Long-press on the link; you should see an option to "Open in [Your App Name]". If you don't, something is wrong. - Safari: Type your link into Safari. If your app is installed and configured correctly, the link should open directly in your app. If it opens in Safari, look for a small banner at the top of the screen that says "Open" or "Open in [Your App Name]". This banner indicates that Universal Links are working but the OS decided to open in Safari first (e.g., if you recently dismissed the app).
xcrun simctl openurl: For simulators, you can use the command line:`bash xcrun simctl openurl booted "https://yourdomain.com/products/123"`- Debugging: Set breakpoints in your
application(_:continue:restorationHandler:)orscene(_:continueUserActivity:)methods to see if the URL is being received.
Common Pitfalls: Incorrect appID in AASA file. AASA file not served over HTTPS, with correct MIME type, or at the correct path. AASA file contains redirects. Incorrect Associated Domains entitlement (e.g., missing applinks: prefix). App not provisioned correctly for Associated Domains. Caching issues (Apple's CDN caches AASA files, sometimes taking time to update). * Using UIWebView or WKWebView to open a Universal Link within your own app (these will not trigger your app's Universal Link handler).
Custom URL Schemes (When Still Useful)
While Universal Links are generally preferred, custom URL schemes still have their place, primarily for inter-app communication when you control both apps. For instance, if you have a suite of apps, one app might launch another using a custom scheme to perform a specific action.
To implement a custom URL scheme:
- Register the Scheme: In your Xcode project, open
Info.plist(or theInfotab in project settings).- Add a new
URL Typesarray. - Add a new item to
URL Types. - Set
URL Schemesto an array containing your desired scheme (e.g.,myapp). - Set
Identifier(e.g.,com.yourcompany.myapp.scheme). - Set
RoletoEditororViewer.
- Add a new
- Handle the Scheme:
- For UIKit apps using
AppDelegate:`swift func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { print("Incoming Custom URL Scheme: (url.absoluteString)") handleIncomingCustomSchemeURL(url) return true }` - For SwiftUI apps or UIKit apps using
SceneDelegate: Use theonOpenURLmodifier as shown for Universal Links, but handle the custom scheme logic within it. TheURLobject will start with your custom scheme (e.g.,myapp://products?id=123).
- For UIKit apps using
func handleIncomingCustomSchemeURL(_ url: URL) {
if url.scheme == "myapp" {
// Parse path and query parameters specific to your 'myapp' scheme
if url.host == "products", let productID = url.queryParameters?["id"] {
print("Custom scheme: Navigating to product with ID: \(productID)")
}
}
}
```
## Summary
Deep linking is a powerful tool to enhance user engagement and provide a seamless experience in your iOS app. While custom URL schemes offer a simple solution for specific inter-app communication scenarios, **Universal Links** are the clear winner for general-purpose deep linking from web content. They provide a secure, reliable, and user-friendly way to connect your website content directly to your app, with a graceful fallback to the web if the app isn't installed.
By carefully configuring your `apple-app-site-association` file on your server and correctly handling `NSUserActivity` in your iOS app, you can unlock a superior deep linking experience for your users.
Happy Swifting!