Skip to main content
Tutorials

Universal Links Setup: Test Universal Links and URL Schemes

Last updated

Universal Links setup needs three things at once: Associated Domains turned on for the App ID, applinks:<your domain> in the entitlements, and the right JSON served at /.well-known/apple-app-site-association on your website. A URL scheme (myapp://product/42) only needs a CFBundleURLTypes entry in Info.plist, which suits app-to-app calls. Apple recommends Universal Links (https://example.com/product/42) for links you share publicly.

Below you’ll choose between the two, configure each, handle links in code, verify on an iPhone and work through ten causes when links don’t open the app. The Apple rules are current as of October 2026 and link to official pages.

Before you start, have a SwiftUI or UIKit project that KXBuild can install and run on your iPhone. For Universal Links you also need a domain where you can upload files and configure HTTPS.

Should I use URL schemes or Universal Links?

URL scheme Universal Links
What the link looks like myapp://product/42 https://example.com/product/42
Where you configure it Only the app’s Info.plist Three places, the App ID capability, the entitlements and a file on your website
Needs a domain and HTTPS No Yes
Uniqueness Any app can register the same scheme, and which one opens is undefined The website and the app verify each other, so no one else can claim your links
When the app isn’t installed Nothing happens or an error appears The page opens in the browser and your website handles it
Shareable on the web and in chats Many apps don’t recognize it as a link It’s a normal URL that works everywhere
When it takes effect As soon as the app is installed The system verifies the website file at install time, and caching can delay it
Best for App-to-app calls, third-party sign-in and payment callbacks Links you share in messages, email, web pages and QR codes

Apple’s guide to defining a custom URL scheme says that when several apps register the same scheme, which one the system opens is undefined, and strongly recommends Universal Links instead. You can set up both, and they can share the same handling code.

How do I set up a URL scheme?

Register CFBundleURLTypes in Info.plist

CFBundleURLTypes is an array, and each item is a dictionary. CFBundleURLSchemes holds the scheme itself, without ://. CFBundleURLName is a reverse-DNS identifier that tells your app apart from others. Set CFBundleTypeRole to Editor for a scheme you define yourself.

 1<key>CFBundleURLTypes</key>
 2<array>
 3	<dict>
 4		<key>CFBundleURLName</key>
 5		<string>com.example.myapp</string>
 6		<key>CFBundleURLSchemes</key>
 7		<array>
 8			<string>myapp</string>
 9		</array>
10		<key>CFBundleTypeRole</key>
11		<string>Editor</string>
12	</dict>
13</array>

Use only lowercase letters, digits, plus signs, periods and hyphens, and start with a letter. Pick a name that’s unlikely to clash with other apps, such as examplemyapp. Avoid generic words like app or test, and don’t use schemes the system reserves, such as http, tel or mailto.

First check whether the target has an Info.plist file. Right-click the target, choose KXBuild: Edit Target Settings and look at Info.plist File on the General page.

  • There’s a path. Double-click that Info.plist in the Explorer to open it in the Property List Editor. Right-click the root dictionary, choose Add Row, enter CFBundleURLTypes as the key and choose Array as the type. Then use Add Child to add the dictionary and array above level by level, and press Ctrl+S to save.
  • It’s empty and Info.plist is generated. Array keys can’t be set through INFOPLIST_KEY_ build settings. Create Config/Info.plist containing only the snippet above and point the target to it. At build time it’s merged with the generated content.
 1<?xml version="1.0" encoding="UTF-8"?>
 2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 3<plist version="1.0">
 4<dict>
 5	<key>CFBundleURLTypes</key>
 6	<array>
 7		<dict>
 8			<key>CFBundleURLName</key>
 9			<string>com.example.myapp</string>
10			<key>CFBundleURLSchemes</key>
11			<array>
12				<string>myapp</string>
13			</array>
14			<key>CFBundleTypeRole</key>
15			<string>Editor</string>
16		</dict>
17	</array>
18</dict>
19</plist>
1kxbuild project add-setting -k INFOPLIST_FILE -v Config/Info.plist -t MyApp -P .\MyApp.xcodeproj

The last section of how to write Info.plist privacy usage descriptions covers both Info.plist setups in more detail. In a Flutter project edit ios/Runner/Info.plist, and in a React Native project edit ios/<ProjectName>/Info.plist. The content is the same.

Check whether another app is installed with canOpenURL

To check whether an app is installed on the phone, first declare its scheme in the LSApplicationQueriesSchemes array in Info.plist. Otherwise canOpenURL always returns false.

1<key>LSApplicationQueriesSchemes</key>
2<array>
3	<string>comgooglemaps</string>
4	<string>spotify</string>
5</array>
 1import UIKit
 2
 3@MainActor
 4func openGoogleMapsIfInstalled() {
 5    guard let url = URL(string: "comgooglemaps://") else { return }
 6    if UIApplication.shared.canOpenURL(url) {
 7        UIApplication.shared.open(url)
 8    } else {
 9        print("Google Maps isn't installed")
10    }
11}

open(_:) isn’t limited by this declaration, so you can skip it when you only want to open the app and don’t need to check first. According to Apple’s canOpenURL documentation, apps built with the iOS 15 SDK or later can declare up to 50 schemes, and apps built with the iOS 27 SDK or later up to 25. List only the ones you actually check.

Both kinds of link end up as a URL. Parse them in one router class that both SwiftUI and UIKit call. Create DeepLinkRouter.swift and replace example.com and myapp with your domain and scheme.

 1import Foundation
 2
 3@MainActor
 4final class DeepLinkRouter: ObservableObject {
 5    enum Destination: Equatable {
 6        case product(id: Int)
 7        case invite(code: String)
 8    }
 9
10    static let scheme = "myapp"
11    static let hosts: Set<String> = ["example.com", "www.example.com"]
12
13    @Published var destination: Destination?
14    @Published private(set) var lastURL = "No link received yet"
15
16    /// Returns true if the link was recognized
17    @discardableResult
18    func handle(_ url: URL) -> Bool {
19        lastURL = url.absoluteString
20        print("Received link: \(url.absoluteString)")
21
22        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
23              let scheme = components.scheme?.lowercased() else { return false }
24
25        var parts = components.path.split(separator: "/").map(String.init)
26        switch scheme {
27        case Self.scheme:
28            // In myapp://product/42 the host is product and the path is /42
29            if let host = components.host { parts.insert(host.lowercased(), at: 0) }
30        case "https":
31            // In https://example.com/product/42 the path is /product/42
32            guard let host = components.host?.lowercased(), Self.hosts.contains(host) else { return false }
33        default:
34            return false
35        }
36
37        // Validate every parameter that comes from outside and drop anything invalid
38        switch parts.first {
39        case "product":
40            guard parts.count >= 2, let id = Int(parts[1]), id > 0 else { return false }
41            destination = .product(id: id)
42            return true
43        case "invite":
44            guard let code = components.queryItems?.first(where: { $0.name == "code" })?.value,
45                  (1...32).contains(code.count),
46                  code.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber) }) else { return false }
47            destination = .invite(code: code)
48            return true
49        default:
50            return false
51        }
52    }
53}

Apple’s guide to supporting Universal Links warns that links are an attack vector into your app. Validate every parameter, and never let a link directly delete data or make a payment. At most, take the user to the right screen and let them confirm.

SwiftUI: onOpenURL

SwiftUI’s onOpenURL receives both URL schemes and Universal Links. Universal Links arrive as a plain URL, so you don’t deal with NSUserActivity. See the onOpenURL documentation. Change your app’s entry file as follows.

 1import SwiftUI
 2
 3@main
 4struct MyAppApp: App {
 5    @StateObject private var router = DeepLinkRouter()
 6
 7    var body: some Scene {
 8        WindowGroup {
 9            LinkTestView()
10                .environmentObject(router)
11                .onOpenURL { url in
12                    router.handle(url)
13                }
14        }
15    }
16}
17
18struct LinkTestView: View {
19    @EnvironmentObject private var router: DeepLinkRouter
20
21    var body: some View {
22        VStack(spacing: 16) {
23            Text("Last received link").font(.headline)
24            Text(router.lastURL).font(.footnote).multilineTextAlignment(.center)
25            switch router.destination {
26            case .product(let id):
27                Text("Open product \(id)").font(.title2)
28            case .invite(let code):
29                Text("Invite code \(code)").font(.title2)
30            case nil:
31                Text("Waiting for a link").foregroundStyle(.secondary)
32            }
33        }
34        .padding()
35    }
36}

In a real app, replace LinkTestView with your root view and switch screens or present details based on router.destination.

UIKit: scene delegate

In a UIKit project with a SceneDelegate, links arrive at two different moments. When the app isn’t running, read them from connectionOptions in scene(_:willConnectTo:options:). When the app is already running or in the background, URL schemes go to scene(_:openURLContexts:) and Universal Links go to scene(_:continue:).

 1import UIKit
 2
 3class SceneDelegate: UIResponder, UIWindowSceneDelegate {
 4    var window: UIWindow?
 5    let router = DeepLinkRouter()
 6
 7    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
 8               options connectionOptions: UIScene.ConnectionOptions) {
 9        // Keep your existing window setup code here
10
11        // Cold launch: URL scheme
12        if let url = connectionOptions.urlContexts.first?.url {
13            router.handle(url)
14        }
15        // Cold launch: Universal Links
16        if let activity = connectionOptions.userActivities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb }),
17           let url = activity.webpageURL {
18            router.handle(url)
19        }
20    }
21
22    // While running: URL scheme
23    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
24        if let url = URLContexts.first?.url {
25            router.handle(url)
26        }
27    }
28
29    // While running: Universal Links
30    func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
31        guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
32              let url = userActivity.webpageURL else { return }
33        router.handle(url)
34    }
35}

In older projects without scenes, implement these two methods in AppDelegate. The return value says whether you handled the link.

 1import UIKit
 2
 3@main
 4class AppDelegate: UIResponder, UIApplicationDelegate {
 5    var window: UIWindow?
 6    let router = DeepLinkRouter()
 7
 8    // URL scheme
 9    func application(_ app: UIApplication, open url: URL,
10                     options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
11        router.handle(url)
12    }
13
14    // Universal Links
15    func application(_ application: UIApplication, continue userActivity: NSUserActivity,
16                     restorationHandler: @escaping ([any UIUserActivityRestoring]?) -> Void) -> Bool {
17        guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
18              let url = userActivity.webpageURL else { return false }
19        return router.handle(url)
20    }
21}

Universal Links rely on the app and the website confirming each other. The app’s signature says “I can open links on example.com”, and a file on the website says “this app may open my links”. Only when both match does the system hand the link to the app.

Step 1: Turn on Associated Domains for the App ID

  1. Sign in to developer.apple.com, open Certificates, Identifiers & Profiles and open your app’s App ID on the Identifiers page.
  2. Check Associated Domains in the Capabilities list and click Save. The App ID must be an explicit Bundle ID. Wildcard App IDs can’t use this capability.
  3. On the Profiles page, open each profile for this App ID (Development, Ad Hoc, App Store), click Edit, click Save without changing anything, then Download.
  4. Replace the old files in your project’s keychain folder with the new ones and delete the old ones.

Double-click the new .mobileprovision in VS Code. If its entitlements include com.apple.developer.associated-domains, the profile has the capability. The value is usually *, and the actual domains come from the app’s entitlements. For how App IDs and profiles relate, see how Bundle IDs, App IDs, certificates and profiles relate.

Step 2: Add applinks domains to the entitlements

Create MyApp.entitlements in the app’s source folder with the content below. If the project already has an entitlements file (for example because it uses push or App Groups), add this key to that file instead of creating a new one.

 1<?xml version="1.0" encoding="UTF-8"?>
 2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 3<plist version="1.0">
 4<dict>
 5	<key>com.apple.developer.associated-domains</key>
 6	<array>
 7		<string>applinks:example.com</string>
 8		<string>applinks:www.example.com</string>
 9	</array>
10</dict>
11</plist>

The format is applinks: followed by the full domain. Write only the domain, with no https://, path, query or trailing slash. example.com and www.example.com are two domains, so list them separately, and each one needs its own website file. To match all subdomains, use applinks:*.example.com. The rules are in Apple’s Supporting associated domains.

Then point the target to this file in either of these ways.

  • In the VS Code extension. Right-click the target, choose KXBuild: Edit Target Settings, enter MyApp/MyApp.entitlements in Code Signing Entitlements in the Signing group of the General page, and press Ctrl+S.
  • On the command line. Run the command below in the project folder. The path is relative to the folder containing the .xcodeproj.
1kxbuild project add-setting -k CODE_SIGN_ENTITLEMENTS -v MyApp/MyApp.entitlements -t MyApp -P .\MyApp.xcodeproj

To change domains later, double-click the .entitlements file and edit it in the Property List Editor. For where the setting lives, see the Project Settings Editor.

Step 3: Host apple-app-site-association on your website

Create a file named apple-app-site-association with no file extension and JSON content. Each entry in appIDs is “Team ID.Bundle ID”. Your Team ID is under Membership details in the account menu at the top right of the developer website, and it’s 10 characters long.

 1{
 2  "applinks": {
 3    "details": [
 4      {
 5        "appIDs": ["ABCDE12345.com.example.myapp"],
 6        "components": [
 7          {
 8            "/": "/admin/*",
 9            "exclude": true,
10            "comment": "Always open admin pages in the browser"
11          },
12          {
13            "/": "/product/*",
14            "comment": "Send product pages to the app"
15          },
16          {
17            "/": "/invite",
18            "?": { "code": "?*" },
19            "comment": "Send invite links with a code parameter to the app"
20          }
21        ]
22      }
23    ]
24  }
25}

components is the newer format, supported since iOS 13. Rules are matched in array order and the first match wins, so put exclude rules first. / matches the path, ? matches query parameters and # matches the fragment. * matches any number of characters, ? matches one character and ?* means at least one character. The fields are defined in Apple’s applinks reference.

Serve the file at the address below, using the same domain as in your entitlements.

1https://example.com/.well-known/apple-app-site-association

Your server must meet these requirements.

Requirement Details
HTTPS only A valid certificate, not self-signed (except in developer mode, see below)
No redirects Return 200 directly. Rules that redirect example.com to www.example.com, or http to https, must not apply to this path
Serve JSON Set Content-Type to application/json. Many servers serve files without an extension as application/octet-stream or text/html by default
Publicly reachable Apple’s CDN must be able to download it directly, with no sign-in and no firewall or bot-protection rule blocking it
One per domain The www and non-www domains must each serve the file

The first two come straight from Apple’s Supporting associated domains. The Content-Type requirement comes from Apple’s earlier documentation, and serving application/json is the safest choice. In nginx you can write this.

1location = /.well-known/apple-app-site-association {
2    default_type application/json;
3}

After you upload it, check it from your computer. The first line should be HTTP/1.1 200 or HTTP/2 200, there should be no location: line, and content-type should be application/json.

1curl -sI https://example.com/.well-known/apple-app-site-association

In Windows PowerShell, type curl.exe instead of curl.

Apple CDN caching and developer mode

Since iOS 14, the phone doesn’t contact your server directly. It downloads the file from a CDN that Apple runs. According to Apple, the CDN requests the file from your server within 24 hours, and after the app is installed the phone checks for updates about once a week. So when you change the website file, phones that already have the app may not pick it up right away.

Open the address below in a browser to see what the CDN has cached. If it differs from your server, the CDN hasn’t refreshed yet.

1https://app-site-association.cdn-apple.com/a/v1/example.com

During development, if you use an internal server or a self-signed certificate, or you want the phone to skip the CDN cache, use developer mode (alternate mode) so the phone talks to your server directly. Apple’s Configuring an associated domain lists three conditions that must all be met.

  1. Add ?mode=developer after the domain in the entitlements, for example applinks:dev.example.com?mode=developer.
  2. Sign the app with a Development profile, which means a Debug build.
  3. Turn on Developer Mode on the phone, and turn on Associated Domains Development in Settings > Developer.

In developer mode your server can use any SSL certificate, including ones the system doesn’t trust. Remove ?mode=developer before you submit to the App Store. If Debug and Release should use different domains, create a separate entitlements file for Release. On the Build Settings page of the Project Settings Editor, expand CODE_SIGN_ENTITLEMENTS per configuration and enter one file for Debug and one for Release.

How do I verify links on an iPhone?

Install a Debug build and watch the log

In the KXBuild view, select Debug under the Configuration node, run KXBuild: Build & Run and install the app on your phone. The system downloads and verifies the website file at install time. After you change the entitlements or the website file, delete the app from the phone before you reinstall so verification runs again. When the app receives a link, a Received link: line appears in Output > KXBuild Console. For breakpoint debugging see Debug iOS apps on Windows with LLDB, breakpoints and crash logs.

Verify the URL scheme

  1. Type myapp://product/42 in Safari’s address bar on the iPhone and tap Go.
  2. The system asks “Open in MyApp?”. Tap Open.
  3. The app launches and shows “Open product 42”. Try it once with the app closed and once with it in the background, so both code paths run.

Verify Universal Links

Universal Links only trigger when you tap them, so you test them differently from normal links.

  1. Create a note in the Notes app on the iPhone containing https://example.com/product/42, or send it to yourself in Messages.
  2. Tap the link. The app should open directly and show “Open product 42”, without going through Safari.
  3. Long-press the link. The menu should include “Open in MyApp”. This is the most direct way to tell whether the system accepts the association.
  4. Try an excluded address too, such as https://example.com/admin/1. It should open in Safari.

In the cases below the app doesn’t open. This is normal system behavior, not a configuration error.

What you do Result Why
Type or paste the link into Safari’s address bar Opens in Safari The user typed a URL, so the system assumes they want the web page
Tap a same-domain link on a page on example.com Opens in Safari Apple says taps within the same domain stay in the browser. Only links from another domain to yours open the app
Open your own Universal Link with UIApplication.shared.open from your own app Doesn’t come back to your app Apple says an app that opens links to its own website with these methods isn’t routed to itself
Earlier you chose “Open in Safari” from the long-press menu Always opens in Safari from then on The system remembers the choice. Long-press the link and choose “Open in MyApp” to restore it

The last two come from Apple’s Allowing apps and websites to link to your content. The in-app browsers of some social and chat apps have their own rules for opening links and may not open your app directly, so rely on Notes and Messages when you test.

Why don’t Universal Links open the app? Ten causes to check

# Cause How to confirm and fix
1 Associated Domains isn’t on for the App ID, or the profile wasn’t regenerated afterwards Double-click the .mobileprovision and look for com.apple.developer.associated-domains in its entitlements. If it’s missing, regenerate the profile and replace it
2 The target doesn’t use the entitlements file Search for CODE_SIGN_ENTITLEMENTS on the Build Settings page of the Project Settings Editor and check the path
3 The domain in the entitlements is wrong No https:// and no path, and list www and non-www separately
4 appIDs is wrong The format is Team ID, a period, then Bundle ID, and both must match what you sign with
5 The file is at the wrong address or has an extension It must be /.well-known/apple-app-site-association, and the file name can’t end in .json
6 The address redirects Check with curl -sI that it returns 200. A location: line means a redirect, so turn off redirect rules for this path
7 Invalid JSON or the wrong Content-Type Check it with a JSON validator and confirm it returns application/json
8 Apple’s CDN still has the old file or can’t fetch it Compare with the CDN address. During development use ?mode=developer, and after launch wait for the CDN to refresh
9 The components rules don’t match Check path case, whether exclude rules come first, and whether query rules are too strict
10 The test method is wrong Don’t type into Safari’s address bar. Tap a link in Notes or Messages, and delete and reinstall the app before testing

To watch the system verify your Universal Links association, run kxdevice syslog with the phone connected and filter it with findstr swcd (grep swcd on macOS and Linux) to see the logs of the system process that handles associated domains. See the kxdevice command reference for the command. For signing and provisioning errors, see 16 common iOS code signing and .ipa install errors, and how to fix them.

FAQ

Should I use Universal Links or a URL scheme?

Use Universal Links for links you share publicly. They’re normal URLs, open the web page when the app isn’t installed, and can’t be claimed by other apps. Use a URL scheme for app-to-app calls and third-party sign-in or payment callbacks. You can set up both and share one parsing routine.

Where does the apple-app-site-association file go?

At https://your-domain/.well-known/apple-app-site-association, with no file extension. It must be served over HTTPS, return 200 directly with no redirect, and use the Content-Type application/json. Every subdomain must serve its own copy.

Why doesn’t my change to apple-app-site-association take effect?

Since iOS 14 the phone gets the file from Apple’s CDN, which updates within 24 hours, and installed apps check about once a week. During development, add ?mode=developer after the domain in the entitlements, turn on Associated Domains Development in Settings > Developer on the phone, then delete and reinstall the app so it reads the file from your server directly.

Why doesn’t typing the URL in Safari open my app?

This is normal system behavior. URLs typed or pasted into the address bar, and taps on links within the same domain, stay in Safari. To test, put the link in a note or send it to yourself in Messages, then tap it.

Do I need NSUserActivity to handle Universal Links in SwiftUI?

No. SwiftUI’s onOpenURL passes Universal Links to you as a URL, through the same callback as URL schemes. Only UIKit projects need to read webpageURL from the NSUserActivity in the scene delegate’s scene(_:continue:).