Edok Studio Edok Studio
Guide

Universal Links on iOS, step by step

Updated September 2, 2026 12 min read

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:

  1. Declare that your app owns the link domain.
  2. Receive the tapped URL in your app.
  3. Ask where that link points.
  4. Route to the screen.
Every code sample below uses 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

ValueExampleWhere to find it
Link domainlinks.example.comYour project → Links → App setup
Team IDA1B2C3D4E5developer.apple.com → Membership
Bundle IDcom.example.appXcode → target → General
Enter the Team ID and Bundle ID in the dashboard — they are what your apple-app-site-association file is built from.

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.

Without this, the entitlement is stripped when the app is signed. There is no error at build time and no error at runtime — links simply open Safari.

Add the entitlement

In Xcode: select your target → Signing & Capabilities+ CapabilityAssociated Domains, then add:

Associated Domains
applinks:links.example.com

That produces YourApp.entitlements:

YourApp.entitlementsxml
<?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:

Check the signed appbash
codesign -d --entitlements :- /path/to/YourApp.app | grep -A3 associated

If 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

MyApp.swiftswift
@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.

SceneDelegate.swiftswift
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)

AppDelegate.swiftswift
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:

EdokLinks.swiftswift
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
        }
    }
}
Keep the timeout short — four seconds is plenty. This request sits between a tap and a screen. A slow answer is worse than no answer: when it expires, show your home screen and move on.

What you get back

Two possible answersjson
{ "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

Routingswift
@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 card

Deferred deep linking on iOS

There is no iOS version of this, from anyone. Apple provides nothing that carries a payload through an App Store install, so a user who taps a link, installs, and opens your app arrives at your home screen.

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

Requesthttp
GET https://links.example.com/l/<slug>
Accept: application/json

The 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.

Response — 200 in both casesjson
{ "matched": true,  "linkId": 12452, "slug": "spring-sale", "deepLinkPath": "/product/123" }
{ "matched": false, "linkId": null,  "slug": null,          "deepLinkPath": null }
FieldTypeMeaning
matchedboolfalse = unknown, inactive, or deleted slug
linkIdint | nullOur id for the link
slugstring | nullThe slug that matched
deepLinkPathstring | nullThe 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.

http
GET https://links.example.com/.well-known/apple-app-site-association

It 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:

What Apple's CDN has cachedbash
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.

FieldExampleWhat it does
NameSummer saleA label for your own reference in the dashboard. Never shown to users, never sent to your app.
Addresssummer-saleThe slug. Produces https://links.example.com/l/summer-sale — this is the URL you share.
Opens in the app/product/123The path your app routes to. Leave blank to open the home screen.
Desktop fallbackhttps://example.com/product/123Where laptops go. Phones without the app are sent to the App Store or Play Store. Defaults to your site.
Link is activetickedUntick 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:

The whole round trip
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

Click counts read lower than reality, and that is expected. When somebody already has your app, the operating system opens it directly. Those taps never reach our servers — or anyone else’s — so no product in this category can count them.

Testing

A URL typed into Safari’s address bar never triggers a Universal Link. iOS treats it as direct navigation and loads the web page instead. This is the single most common reason people believe Universal Links are broken. Put the link in Notes, tap outside the text so it turns blue, then tap it. Messages and Mail work too.

On the Simulator you can script it:

Simulatorbash
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

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.

Frequently asked questions

Why do my Universal Links open Safari instead of my app?

In order of likelihood: Associated Domains is not enabled on the App ID at developer.apple.com, so the entitlement was stripped when the app was signed; the provisioning profile predates enabling that capability and needs regenerating; iOS has not fetched your association file yet, which it does in the background at install time; or you typed the URL into Safari’s address bar, which iOS treats as direct navigation and never routes to an app. Check the entitlement reached the binary with codesign -d --entitlements :- YourApp.app.

Which callback receives a Universal Link on iOS?

In SwiftUI, .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) handles both cold start and foreground. In UIKit with scenes you need two: scene(_:willConnectTo:options:) for the cold start, reading connectionOptions.userActivities, and scene(_:continue:) for a tap while the app is running. Implementing only the second is why a link works when the app is open and does nothing when it is closed.

Does deferred deep linking work on iOS?

No, and not for any provider. Apple offers no mechanism that carries a payload through an App Store install, so a user who taps a link, installs and opens your app arrives at your home screen. The only alternative is probabilistic matching on IP address and device characteristics, which breaks on shared networks and under iCloud Private Relay. What works: on your post-install screen, ask the user to tap the link again — by then it is an ordinary Universal Link.

Do I need to host apple-app-site-association myself?

You need it served over HTTPS at /.well-known/apple-app-site-association on the link domain, as application/json, with no file extension and no redirect — Apple does not follow redirects for this file. Edok Studio serves it for you from your project’s link domain, built from the Team ID and Bundle ID you enter, so there is nothing to upload.

Can I test a Universal Link by typing it into Safari?

No. A URL entered in the address bar is direct navigation and loads the web page — this is the single most common reason people believe Universal Links are broken. Paste the link into Notes, tap outside the text so it turns into a link, then tap it. Messages and Mail work too. On the Simulator, xcrun simctl openurl booted "https://links.example.com/l/your-slug".

Keep reading