This guide takes a native iOS app from nothing to a link that opens the right screen. Swift, no third-party dependencies, and no SDK — Universal Links are an operating-system feature, and the work is configuration plus about forty lines of your own code.
Four steps, and only the first one needs an app release:
- Declare that your app owns the link domain.
- Receive the tapped URL in your app.
- Ask where that link points.
- Route to the screen.
links.example.com as the link domain. Replace it with the domain shown on your project’s Links → App setup tab — that tab also holds the Team ID and Bundle ID fields, and shows the same snippets with your real values already filled in.Before you start
| Value | Example | Where to find it |
|---|---|---|
| Link domain | links.example.com | Your project → Links → App setup |
| Team ID | A1B2C3D4E5 | developer.apple.com → Membership |
| Bundle ID | com.example.app | Xcode → target → General |
We host apple-app-site-association for you at the right path, with the right content type and no redirect. There is nothing to upload and no web server to run.
1. Declare the domain
Enable the capability on the App ID
This step happens on Apple’s website, not in Xcode, and skipping it is the most common reason Universal Links silently do nothing.
- developer.apple.com → Certificates, Identifiers & Profiles → Identifiers
- Select your App ID
- Tick Associated Domains
- Save, then regenerate your provisioning profile
Add the entitlement
In Xcode: select your target → Signing & Capabilities → + Capability → Associated Domains, then add:
applinks:links.example.comThat produces YourApp.entitlements:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:links.example.com</string>
</array>
</dict>
</plist>Confirm CODE_SIGN_ENTITLEMENTS is set for every build configuration — Debug, Release and any Profile or Staging configuration you have. Missing it on one is the classic “works on my machine, not in TestFlight”.
Then verify the entitlement actually reached the binary:
codesign -d --entitlements :- /path/to/YourApp.app | grep -A3 associatedIf associated-domains is not in that output, the link will never reach your app no matter what your code does. Apple’s own reference for this file is Supporting associated domains.
2. Receive the URL
Which callback fires depends on how your app is structured. Implement the one that matches — and remember there are always two cases: a cold start, and a tap while the app is already running.
SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
// Universal Links — handles both cold start and foreground.
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
guard let url = activity.webpageURL else { return }
Task { await handle(url) }
}
// Custom scheme, if you configured one.
.onOpenURL { url in
Task { await handle(url) }
}
}
}
}UIKit with a SceneDelegate
Both methods are required. scene(_:willConnectTo:options:) is the cold start — scene(_:continue:) is never called for a launch, which is exactly why a link works when the app is open and does nothing when it is closed.
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Cold start
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
if let activity = connectionOptions.userActivities.first,
activity.activityType == NSUserActivityTypeBrowsingWeb,
let url = activity.webpageURL {
Task { await handle(url) }
}
// Custom scheme cold start
if let url = connectionOptions.urlContexts.first?.url {
Task { await handle(url) }
}
}
// Already running
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
Task { await handle(url) }
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }
Task { await handle(url) }
}
}UIKit with only an AppDelegate (no scenes)
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return false }
Task { await handle(url) }
return true
}
func application(_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
Task { await handle(url) }
return true
}3. Resolve the link
A Smart Link URL carries a slug, not a destination — /l/spring-sale, not /product/123. That indirection is the point: you can re-point a link that is already out in the world without shipping an app update. It also means that when your app is installed, the operating system hands the URL straight to your app and never contacts us, so your app has to ask where the slug points.
Request the link URL itself with Accept: application/json:
import Foundation
enum EdokLinks {
private static let timeout: TimeInterval = 4
/// Returns the in-app path for `url`, or nil if it cannot be resolved.
/// Never throws — a link tap needs an answer or a nil.
static func resolve(_ url: URL) async -> String? {
var request = URLRequest(url: url, timeoutInterval: timeout)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await noRedirectSession.data(for: request)
guard let http = response as? HTTPURLResponse,
http.statusCode == 200,
let type = http.value(forHTTPHeaderField: "Content-Type"),
type.contains("json") else { return nil }
let body = try JSONDecoder().decode(ResolveResponse.self, from: data)
// matched:false is the normal answer for a slug that is unknown,
// switched off, or deleted. A link can outlive whoever shared it.
guard body.matched, let path = body.deepLinkPath, !path.isEmpty else { return nil }
return path
} catch {
// No connectivity, DNS failure, TLS error, a captive portal serving
// HTML — all of it must land the user in the app, never on a crash.
return nil
}
}
private struct ResolveResponse: Decodable {
let matched: Bool
let linkId: Int?
let slug: String?
let deepLinkPath: String?
}
/// URLSession follows redirects by default. A 3xx from a link means we were
/// answered as a browser, and that response has no destination in it — so
/// following it costs a second request to reach the same nothing.
private static let noRedirectSession: URLSession = {
URLSession(configuration: .default, delegate: NoRedirect(), delegateQueue: nil)
}()
private final class NoRedirect: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil) // stop here, hand us the 3xx
}
}
}What you get back
{ "matched": true, "linkId": 12452, "slug": "spring-sale", "deepLinkPath": "/product/123" }
{ "matched": false, "linkId": null, "slug": null, "deepLinkPath": null }matched: false is the normal answer for a slug that is unknown, switched off, or deleted. A link outlives whoever shared it and people do tap old ones, so treat it as “could not open this”, never as an error.
4. Route
@MainActor
func handle(_ url: URL) async {
guard let path = await EdokLinks.resolve(url) else { return }
route(path)
}
@MainActor
func route(_ path: String) {
// Treat path as untrusted input: match it against routes you know rather
// than passing segments straight into a query.
let segments = path.split(separator: "/").map(String.init)
switch segments {
case ["product", let id]:
navigate(to: .product(id: id))
default:
break // unknown route: stay where you are
}
}If your app has a sign-in or onboarding flow, hold the path rather than dropping it. A link routinely arrives before there is anywhere to send it, and the destination should open once the user gets through.
Host your link domain and both verification files in Edok Studio
Design once with device frames and export every required App Store & Google Play size in one ZIP — watermark-free on the free plan.
Start free — no cardDeferred deep linking on iOS
The only alternative is guessing from IP address and device characteristics, which fails on any shared network and under iCloud Private Relay — and is the sort of tracking we will not do. What works reliably and for free: on your post-install screen, tell the user to tap the link again. By then it is an ordinary Universal Link and opens the right screen.
Android does have a deterministic mechanism for this, because the Play Store carries an install referrer through the install — see the Android guide.
API reference
Everything your app talks to. No API key and no authentication — these endpoints are public, because an app installed on someone’s phone has no credentials it could safely present.
Resolve a link
GET https://links.example.com/l/<slug>
Accept: application/jsonThe same URL a browser opens. The Accept header is the whole difference: without it you get the human-facing interstitial page and a redirect to the store.
{ "matched": true, "linkId": 12452, "slug": "spring-sale", "deepLinkPath": "/product/123" }
{ "matched": false, "linkId": null, "slug": null, "deepLinkPath": null }| Field | Type | Meaning |
|---|---|---|
matched | bool | false = unknown, inactive, or deleted slug |
linkId | int | null | Our id for the link |
slug | string | null | The slug that matched |
deepLinkPath | string | null | The path to route to, e.g. /product/123 |
Do not follow redirects on this request. A 3xx means you were answered as a browser — that response has no destination in it, and following it costs a second round trip to reach the same nothing.
Verification file
We serve this for you; you never upload anything.
GET https://links.example.com/.well-known/apple-app-site-associationIt returns application/json with no file extension and no redirect — exactly what Apple and Google require. Open it in a browser to see what they see.
To check the copy Apple’s CDN is actually serving to devices:
curl "https://app-site-association.cdn-apple.com/a/v1/links.example.com"Creating a link
In Edok Studio, open your project → Links → + New link.
| Field | Example | What it does |
|---|---|---|
| Name | Summer sale | A label for your own reference in the dashboard. Never shown to users, never sent to your app. |
| Address | summer-sale | The slug. Produces https://links.example.com/l/summer-sale — this is the URL you share. |
| Opens in the app | /product/123 | The path your app routes to. Leave blank to open the home screen. |
| Desktop fallback | https://example.com/product/123 | Where laptops go. Phones without the app are sent to the App Store or Play Store. Defaults to your site. |
| Link is active | ticked | Untick to switch a link off without deleting it. |
“Opens in the app” is the exact string your app receives. Whatever you type there comes back as deepLinkPath and lands in your routing function unchanged:
Dashboard: Opens in the app → /product/123
Resolve: {"matched": true, "deepLinkPath": "/product/123"}
Your code: route("/product/123") → navigate(to: .product(id: "123"))So it has to be a path your router already understands. There is no translation layer — pick the same paths your app uses internally.
Things worth knowing
- Changing a link needs no app release. Point
summer-saleat a different screen whenever you like; links already shared follow. That is the whole reason the URL carries a slug instead of a path. - Adding a link needs no configuration change either. Your verification files claim
/l/*, so every future slug is already covered — no waiting on Apple’s CDN, no Android re-verification. - Set the desktop fallback per link. Left blank it goes to your site root, which drops all context for anyone who clicks on a laptop.
- Share the URL without a trailing slash.
https://links.example.com/l/summer-sale, not.../summer-sale/.
Testing
On the Simulator you can script it:
xcrun simctl openurl booted "https://links.example.com/l/your-slug"There is no equivalent for a physical device — iOS has no adb-style remote URL launcher, so device testing is a manual tap.
Association status
iOS fetches your association file once, in the background, at install time. If you install and tap immediately, the fetch may not have finished and the link opens Safari. Force-quit the app, wait a minute, and try again.
On the device, Settings → Developer → Universal Links → Diagnostics lets you paste a URL and see whether iOS considers your app associated with it.
Troubleshooting
- Links open Safari instead of the app. In order of likelihood: Associated Domains is not enabled on the App ID, so the entitlement was stripped at signing (check with
codesign -d --entitlements :-); the provisioning profile predates enabling the capability, so regenerate it; the association has not been fetched yet, so force-quit, wait and retry; or you typed the URL into Safari rather than tapping a link. - Works in Debug, not in TestFlight.
CODE_SIGN_ENTITLEMENTSis missing from your Release configuration. - The link opens the app but lands on the home screen. Your resolve call is returning nil. Log the status code and
Content-Type; anything in the 3xx range means the request did not carryAccept: application/json, or your session is following redirects. - Nothing happens on a cold start, but it works when the app is open. You implemented
scene(_:continue:)but notscene(_:willConnectTo:options:). The launch case arrives throughconnectionOptions.userActivities. - Changes to the association file are not picked up. Apple caches it through a CDN, so devices that already saw the old version can take a while. Reinstalling the app forces a fresh fetch.
Building for more than one toolchain? See the native Android guide and the Flutter guide, or the platform-level explanation of why none of this needs an SDK.