Smart Links — iOS Setup Guide
---
sidebar_label: Smart Links — iOS sidebar_category: Smart Links Integration
Smart Links — iOS Setup Guide
This guide covers everything you need to do inside your iOS app to support Linkzly Smart Links (called Deeplinks in the dashboard). When a user taps a Smart Link on iOS, the goal is for your app to open directly to the correct screen. If the app is not installed, Linkzly redirects to the App Store, and after the user installs and opens the app, Linkzly delivers the original deep link destination — this is called deferred deep linking.
What Linkzly does automatically:
- Hosts the Apple App Site Association (AASA) file at
https://<your-domain>/.well-known/apple-app-site-association - Handles deferred deep link storage and delivery
- Routes uninstalled users to the App Store URL you configured
- Tracks clicks, installs, and deep link delivery events
What you must do in Xcode:
- Enable the Associated Domains capability and add your Linkzly domain
- Handle the incoming Universal Link URL in your app delegate or scene delegate
- Parse the URL path and query parameters to navigate to the correct screen
Prerequisites
Before starting the Xcode configuration, complete the app registration in the Linkzly console. The iOS setup requires the following:
| Requirement | Where to Find It |
|---|---|
| Bundle ID | Xcode → Your Target → General → Bundle Identifier. Example: com.yourcompany.yourapp |
| Team ID | Apple Developer portal → Account → Membership. A 10-character string like AB12CD34EF. |
| Linkzly domain | Linkzly Console → Apps → [Your App] → the hosted subdomain or your custom domain. Example: yourapp.linkz.ly |
| Smart Link (Deeplink) | At least one Smart Link created and in Active status in Linkzly Console → Deeplink |
If you have not yet registered your app in Linkzly, follow the steps in the Smart Links overview first — specifically Step 1 (Register an App) and Step 3 (Create a Deeplink). Come back here once you have your Linkzly domain and at least one Smart Link created.
Step 1 — Enable Associated Domains in Xcode
Universal Links work by having iOS verify that your app is authorized to handle URLs from a specific domain. iOS fetches the AASA file that Linkzly automatically hosts on your domain during app install and at regular intervals. You simply need to declare that your app wants to handle that domain.
- Open your project in Xcode.
- Click your app target in the project navigator.
- Click the Signing & Capabilities tab.
- Click the + Capability button in the top-left of the tab.
- Search for Associated Domains and double-click it to add it.
Once the capability is added, an Associated Domains section appears. Click the + button inside it and add an entry for each domain Linkzly uses for your Smart Links:
applinks:yourapp.linkz.ly
If you are using a custom domain (e.g., go.yourcompany.com) instead of the default hosted subdomain, add that:
applinks:go.yourcompany.com
If you have Smart Links on both a custom domain and the default Linkzly hosted domain, add both entries:
applinks:yourapp.linkz.ly
applinks:go.yourcompany.com
The prefix applinks: is required — it is the iOS entitlement type for Universal Links. Do not include https:// or a trailing slash.
After adding the capability, Xcode adds it to your .entitlements file. Verify it looks correct:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:yourapp.linkz.ly</string>
</array>
Note: The Associated Domains capability requires a paid Apple Developer Program membership. A provisioning profile that includes the entitlement must be selected in your target's Signing settings. If you see a code signing error after adding the capability, regenerate your provisioning profile from the Apple Developer portal or let Xcode manage signing automatically.
Step 2 — Verify the AASA File
Linkzly hosts the Apple App Site Association file for you automatically. You do not need to create or upload it. Once you save your app registration in the Linkzly console with your Bundle ID and Team ID, the file is immediately available at:
https://<your-linkzly-domain>/.well-known/apple-app-site-association
Verify it is reachable and contains the correct values by running this command in Terminal, replacing the domain with yours:
curl https://yourapp.linkz.ly/.well-known/apple-app-site-association
The response will be a JSON document with a structure similar to:
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": ["AB12CD34EF.com.yourcompany.yourapp"],
"components": [
{
"/": "/*",
"comment": "Matches all Smart Link paths"
}
]
}
]
}
}
The appIDs value is your Team ID, a period, and your Bundle ID. If the value does not match your app exactly — including capitalization — Universal Links will not work. Double-check the Bundle ID and Team ID values entered in the Linkzly console.
Note: iOS caches the AASA file aggressively. After making changes to your app registration in Linkzly, it may take up to 24 hours for existing devices to pick up the updated file. Testing on a freshly installed build is the most reliable method during development.
Step 3 — Handle the Universal Link in Your App
When a user taps a Linkzly Smart Link and your app is installed, iOS calls a method in your app delegate or scene delegate with the incoming URL. You need to implement that method and write the routing logic inside it.
Option A: SwiftUI
If your app uses the SwiftUI App protocol, attach the .onOpenURL modifier to your root view. This modifier receives both Universal Links and custom URI scheme URLs:
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { url in
DeepLinkRouter.shared.handle(url: url)
}
}
}
}
Option B: UIKit with SceneDelegate (iOS 13+)
If your app uses SceneDelegate, implement both the cold-launch handler (for when the app was not running when the link was tapped) and the foreground handler (for when the app was already running):
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
// Called on cold launch via Universal Link — app was not running
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
if let userActivity = connectionOptions.userActivities.first,
userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL {
DeepLinkRouter.shared.handle(url: url)
}
}
// Called when the app is already running and receives a Universal Link
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else {
return
}
DeepLinkRouter.shared.handle(url: url)
}
// Called when a custom URI scheme opens the app (yourapp://)
func scene(
_ scene: UIScene,
openURLContexts URLContexts: Set<UIOpenURLContext>
) {
guard let url = URLContexts.first?.url else { return }
DeepLinkRouter.shared.handle(url: url)
}
}
Option C: UIKit with AppDelegate only (no SceneDelegate)
For apps that do not use SceneDelegate, implement these two methods in AppDelegate:
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
// Called when a Universal Link (https://) opens the app
func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else {
return false
}
DeepLinkRouter.shared.handle(url: url)
return true
}
// Called when a custom URI scheme (yourapp://) opens the app
func application(
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
DeepLinkRouter.shared.handle(url: url)
return true
}
}
Step 4 — Parse the URL and Route to the Right Screen
Linkzly Smart Links pass routing information as the URL path and as query parameters. The URL a user taps looks like this:
https://yourapp.linkz.ly/abc123
When iOS opens your app, the URL Linkzly passes through to the app contains the deep_link_path you configured in the dashboard and any additional Deep Link Data key-value pairs you set on the Smart Link — both appended as query parameters:
https://yourapp.linkz.ly/abc123?deep_link_path=%2Fproduct%2F456&campaign=summer&discount=20
Your routing code should:
- Extract the
deep_link_pathquery parameter (URL-decoded) — this is the path you set in the Linkzly Deeplink editor. - Extract any additional query parameters as supplementary data.
- Navigate the user to the appropriate screen based on the path.
Here is a complete DeepLinkRouter implementation in Swift:
import Foundation
final class DeepLinkRouter: ObservableObject {
static let shared = DeepLinkRouter()
// Publish the pending route so SwiftUI views can react
@Published var pendingRoute: AppRoute?
private init() {}
func handle(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
let queryItems = components.queryItems ?? []
// Extract the deep_link_path set in the Linkzly dashboard.
// Fall back to the raw URL path if deep_link_path is not present.
let rawPath = queryItems
.first(where: { $0.name == "deep_link_path" })?
.value ?? components.path
// Collect remaining query params as supplementary data
var params: [String: String] = [:]
for item in queryItems where item.name != "deep_link_path" {
if let value = item.value {
params[item.name] = value
}
}
route(path: rawPath, params: params)
}
private func route(path: String, params: [String: String]) {
// Normalize: strip leading slash, split into path segments
let segments = path
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
.split(separator: "/")
.map(String.init)
guard let section = segments.first else {
pendingRoute = .home
return
}
switch section {
case "product":
// deep_link_path = /product/456
let productId = segments.count > 1 ? segments[1] : ""
pendingRoute = .product(id: productId, params: params)
case "promo":
// deep_link_path = /promo/summer-sale
let slug = segments.count > 1 ? segments[1] : ""
pendingRoute = .promo(slug: slug)
case "profile":
// deep_link_path = /profile/user123
let userId = segments.count > 1 ? segments[1] : ""
pendingRoute = .profile(userId: userId)
case "onboarding":
// deep_link_path = /onboarding + data param: step=welcome
let step = params["step"] ?? "welcome"
pendingRoute = .onboarding(step: step)
case "article":
// deep_link_path = /article/789
let articleId = segments.count > 1 ? segments[1] : ""
pendingRoute = .article(id: articleId)
default:
// Unknown path — fall back to home
pendingRoute = .home
}
}
}
// Define your app's navigable routes
enum AppRoute: Hashable {
case home
case product(id: String, params: [String: String])
case promo(slug: String)
case profile(userId: String)
case onboarding(step: String)
case article(id: String)
}
Wiring the Router to SwiftUI Navigation
Connect DeepLinkRouter.pendingRoute to a NavigationStack in your root view so the app navigates automatically when a deep link arrives:
import SwiftUI
struct ContentView: View {
@StateObject private var router = DeepLinkRouter.shared
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
destinationView(for: route)
}
}
.onChange(of: router.pendingRoute) { route in
guard let route = route else { return }
navigationPath.append(route)
router.pendingRoute = nil
}
}
@ViewBuilder
private func destinationView(for route: AppRoute) -> some View {
switch route {
case .home:
HomeView()
case .product(let id, let params):
ProductDetailView(productId: id, campaignParams: params)
case .promo(let slug):
PromoView(promoSlug: slug)
case .profile(let userId):
ProfileView(userId: userId)
case .onboarding(let step):
OnboardingView(startingStep: step)
case .article(let id):
ArticleView(articleId: id)
}
}
}
Wiring the Router to UIKit Navigation
For UIKit apps, observe pendingRoute using Combine and push or present the appropriate view controller:
import UIKit
import Combine
class MainViewController: UIViewController {
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
DeepLinkRouter.shared.$pendingRoute
.compactMap { $0 }
.receive(on: DispatchQueue.main)
.sink { [weak self] route in
self?.navigate(to: route)
DeepLinkRouter.shared.pendingRoute = nil
}
.store(in: &cancellables)
}
private func navigate(to route: AppRoute) {
switch route {
case .home:
break // Already on home
case .product(let id, _):
let vc = ProductDetailViewController(productId: id)
navigationController?.pushViewController(vc, animated: true)
case .promo(let slug):
let vc = PromoViewController(slug: slug)
navigationController?.pushViewController(vc, animated: true)
case .profile(let userId):
let vc = ProfileViewController(userId: userId)
navigationController?.pushViewController(vc, animated: true)
case .onboarding(let step):
let vc = OnboardingViewController(startingStep: step)
present(vc, animated: true)
case .article(let id):
let vc = ArticleViewController(articleId: id)
navigationController?.pushViewController(vc, animated: true)
}
}
}
Step 5 — Deferred Deep Linking
When a user taps a Smart Link but does not have your app installed, Linkzly redirects them to the App Store URL you configured in the app registration. After the user installs the app and opens it for the first time, iOS delivers the original Universal Link through the standard NSUserActivity mechanism.
No extra code is required in your app for deferred deep linking. The same scene(_:continue:) / application(_:continue:restorationHandler:) / .onOpenURL handler you already implemented in Step 3 also receives the deferred link. When the app launches fresh after being installed from a Smart Link tap, iOS fires the callback with the original destination URL and your routing logic navigates the user to the right screen.
The scene(_:willConnectTo:options:) method in SceneDelegate is particularly important for this scenario — it handles the cold-launch case where the app was not running when the link was first tapped and the user just opened it for the first time after installing.
Requirement: For deferred deep linking to work, the iOS App Store URL field in your Linkzly app registration must contain a valid App Store link for your app. This is the URL Linkzly redirects uninstalled users to so they can install.
Optional: Custom URI Scheme Fallback
Universal Links are the preferred approach. A custom URI scheme is optional but useful as a fallback for scenarios where Universal Links may not trigger — for example, when linking from inside a third-party app or a WebView that does not honor Universal Links.
If you set a Custom URL Scheme in your Linkzly app registration (e.g., myapp://), you also need to register it in your Info.plist:
- Open
Info.plistin Xcode. - Add a top-level key:
URL types(type: Array) if it does not already exist. - Add an item inside it (type: Dictionary).
- Inside that item, add
URL Schemes(type: Array) and add a String item with your scheme without the://— for example,myapp. - Optionally set
URL identifierto your bundle ID.
The same handler code you wrote in Step 3 already handles custom URI scheme URLs through the scene(_:openURLContexts:) and application(_:open:options:) callbacks. No separate implementation is needed — the DeepLinkRouter.shared.handle(url:) call works the same way for both Universal Links and custom scheme URLs.
Testing
Test on the iOS Simulator
Use the xcrun simctl command from Terminal to open a Smart Link in the simulator without needing a physical device:
xcrun simctl openurl booted "https://yourapp.linkz.ly/abc123"
Replace yourapp.linkz.ly/abc123 with one of your actual Smart Link URLs from the Linkzly console. If the Associated Domains capability is configured correctly, the simulator opens your app and triggers your URL handler.
Test on a Physical Device
- Build and install the app on your device via Xcode or TestFlight.
- Open the Notes app and type your Smart Link URL as a tappable link.
- Tap the link. If Universal Links are configured correctly, iOS opens your app immediately instead of loading the URL in Safari.
Important: Universal Links are not triggered when you paste a URL directly into the Safari address bar and press Go. You must tap a link from another app context — Notes, Messages, Mail, or a native app — to trigger the Universal Link behavior.
Test Deferred Deep Linking
- Uninstall the app from your device.
- Tap the Smart Link from Messages or Notes.
- Confirm Linkzly redirects you to the App Store listing for your app.
- Install the app from the App Store.
- Open the app. The
scene(_:willConnectTo:options:)callback should fire with the original URL, and your app should navigate to the correct screen.
Simulate the Deferred Link on the Simulator
# <span id="launch-the-app-as-if-it-was-opened-after-a-deferred-deep-link-install"></span>Launch the app as if it was opened after a deferred deep link install
xcrun simctl openurl booted "https://yourapp.linkz.ly/abc123"
Build and run the app fresh in the simulator after running the command above to simulate the cold-launch scenario.
Troubleshooting
Universal Links open in Safari instead of the app
- Verify the
applinks:domain entry in your.entitlementsfile matches your Linkzly domain exactly — nohttps://, no trailing slash, no path. - Confirm the AASA file is reachable via
curland theappIDsvalue matches<TeamID>.<BundleID>exactly. Both are case-sensitive. - Reinstall the app. iOS only fetches the AASA file at install time. Changes to the AASA file are not applied until you reinstall.
- Confirm you are tapping the link from a native app context (Notes, Messages, Mail), not typing it into Safari's address bar.
Associated Domains capability is grayed out in Xcode
This capability requires a paid Apple Developer Program membership. Free development accounts with a personal team do not support Associated Domains. Ensure your target is signed with a provisioning profile from a paid account.
AASA file returns 404 or an HTML page
Allow a few minutes after first registering your app in Linkzly. If it persists beyond 5 minutes, verify in the Linkzly console that your app registration was saved successfully and that the domain is shown as configured. For custom domains, confirm your DNS CNAME record points to Linkzly's servers and has propagated.
The scene(_:continue:) method is never called on cold launch
Implement scene(_:willConnectTo:options:) in your SceneDelegate and check connectionOptions.userActivities for the Universal Link. On iOS 13+ with SceneDelegate, a cold launch via Universal Link routes through willConnectTo, not continue.
Deep link path is not extracted correctly
Log the full incoming URL in your handler to inspect what Linkzly is sending:
func handle(url: URL) {
print("[DeepLink] Received URL: \(url.absoluteString)")
// ...
}
Verify the deep_link_path query parameter is present and URL-decoded correctly. In the Linkzly console, check the Smart Link's Deep Link Path field — ensure you are using the Advanced link type if you expect path and data parameters to be forwarded.
Complete Working Example
This is a self-contained SwiftUI app that combines all the steps in this guide:
import SwiftUI
import Combine
// MARK: - Entry Point
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
RootView()
.onOpenURL { url in
DeepLinkRouter.shared.handle(url: url)
}
}
}
}
// MARK: - Routes
enum AppRoute: Hashable {
case home
case product(id: String, params: [String: String])
case promo(slug: String)
case profile(userId: String)
case onboarding(step: String)
}
// MARK: - Router
final class DeepLinkRouter: ObservableObject {
static let shared = DeepLinkRouter()
@Published var pendingRoute: AppRoute?
private init() {}
func handle(url: URL) {
print("[DeepLink] Received: \(url.absoluteString)")
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return
}
let queryItems = components.queryItems ?? []
let path = queryItems
.first(where: { $0.name == "deep_link_path" })?
.value ?? components.path
var params: [String: String] = [:]
for item in queryItems where item.name != "deep_link_path" {
if let value = item.value { params[item.name] = value }
}
let segments = path
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
.split(separator: "/")
.map(String.init)
switch segments.first {
case "product":
pendingRoute = .product(id: segments.count > 1 ? segments[1] : "", params: params)
case "promo":
pendingRoute = .promo(slug: segments.count > 1 ? segments[1] : "")
case "profile":
pendingRoute = .profile(userId: segments.count > 1 ? segments[1] : "")
case "onboarding":
pendingRoute = .onboarding(step: params["step"] ?? "welcome")
default:
pendingRoute = .home
}
}
}
// MARK: - Root Navigation View
struct RootView: View {
@StateObject private var router = DeepLinkRouter.shared
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .home:
HomeView()
case .product(let id, let params):
Text("Product: \(id), campaign: \(params["campaign"] ?? "none")")
case .promo(let slug):
Text("Promo: \(slug)")
case .profile(let userId):
Text("Profile: \(userId)")
case .onboarding(let step):
Text("Onboarding step: \(step)")
}
}
}
.onChange(of: router.pendingRoute) { route in
guard let route = route else { return }
navigationPath.append(route)
router.pendingRoute = nil
}
}
}
struct HomeView: View {
var body: some View {
Text("Home").navigationTitle("Home")
}
}
Setup Checklist
- App registered in Linkzly console with the correct Bundle ID and Team ID.
- At least one Smart Link (Deeplink) created and set to Active status.
- Associated Domains capability added in Xcode with
applinks:<your-linkzly-domain>. - AASA file verified at
https://<your-domain>/.well-known/apple-app-site-association—appIDsvalue matches<TeamID>.<BundleID>exactly. -
scene(_:willConnectTo:options:)implemented to handle cold launch via Universal Link. -
scene(_:continue:)or.onOpenURLimplemented to handle Universal Links when app is running. -
scene(_:openURLContexts:)implemented if using a custom URI scheme fallback. -
deep_link_pathquery parameter extracted and used for screen routing. - All expected deep link paths handled in the router — unknown paths fall back gracefully.
- Tested on a physical device in a native app context (Notes or Messages) — link opens the correct screen.
- Tested deferred deep linking: uninstalled app, tapped link, installed from App Store, opened app, confirmed the correct screen was delivered.
Was this helpful?
Help us improve our documentation