iOS Universal Links Guide

Complete step-by-step guide to implement, configure, and test iOS Universal Links in your mobile application

1. Overview & Prerequisites

What are iOS Universal Links?

iOS Universal Links are HTTPS URLs that allow users to intelligently follow links to content inside your app or to your website. When a Universal Link is opened, iOS checks if any installed app can handle that link, and if so, opens the app instead of a web browser.

Types of iOS Deep Links

Custom URL Schemes

Basic app-specific URL schemes

myapp://product/123
Standard Web Links

HTTPS URLs that open in Safari

https://example.com/product/123
Universal Links

Verified HTTPS URLs that open directly in app

https://verified.com/product/123
Key Benefits
  • • Seamless transition from web to app
  • • Works even if your app isn't installed (falls back to website)
  • • No custom URL scheme registration needed
  • • Better user experience and engagement
  • • Secure domain verification process

Prerequisites

Development
  • • Xcode 12.0 or later
  • • iOS 14.0+ deployment target
  • • Swift 5.3+ knowledge
  • • Apple Developer Account
Web/Domain
  • • Domain ownership
  • • HTTPS enabled
  • • Web server access
  • • SSL certificate

2. Configure Associated Domains

Add Associated Domains Capability

Configure your Xcode project to declare which domains your app can handle Universal Links for.

Xcode Project Settings

1. Open your Xcode project

2. Select your app target

3. Go to Signing & Capabilities tab

4. Click + Capability

5. Add "Associated Domains"

6. Add your domains with applinks: prefix

Domain Configuration Examples

Associated Domains
applinks:yourdomain.com
applinks:www.yourdomain.com
applinks:api.yourdomain.com
applinks:subdomain.yourdomain.com

// For development/testing
applinks:dev.yourdomain.com
applinks:staging.yourdomain.com

Important Notes

Important
  • • Always use applinks: prefix (not https://)
  • • Don't include paths in the domain entry
  • • Add both www and non-www versions if needed
  • • Each domain requires a separate entry

3. Setup App Site Association

Create apple-app-site-association File

This file tells iOS which app should handle Universal Links for your domain. Create this JSON file and host it on your web server.

Format Note

This example uses the modern components format (iOS 13+). For legacy support, you can also use the paths format, but components provides more flexibility and better fragment handling.

apple-app-site-association
{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.yourcompany.yourapp"],
        "components": [
          {
            "/": "/product/*",
            "comment": "Handles product pages"
          },
          {
            "/": "/dashboard",
            "comment": "Handles dashboard"
          },
          {
            "/": "/profile/*",
            "comment": "Handles profile pages"
          },
          {
            "/": "/admin/*",
            "exclude": true,
            "comment": "Excludes admin pages"
          }
        ]
      }
    ]
  },
  "webcredentials": {
    "apps": ["TEAMID.com.yourcompany.yourapp"]
  }
}

Components Pattern Examples

Include Patterns

{ "/": "/product/*" }

All product pages

{ "/": "/dashboard" }

Exact path match

{ "/": "/*" }

All paths (use carefully)

Exclusion Patterns

{ "/": "/admin/*", "exclude": true }

Exclude admin pages

{ "#": "no_universal_links", "exclude": true }

Exclude URLs with fragment

{ "/": "*.pdf", "exclude": true }

Exclude PDF files

Find Your Team ID

Method 1: Apple Developer Portal

Go to developer.apple.com/account → Membership → Team ID

Format: 10 alphanumeric characters (e.g., TEAM123456)

Method 2: Xcode

Project → Target → Signing & Capabilities → Team dropdown shows Team ID in parentheses

Look for text like "Team Name (TEAM123456)"

Method 3: App Store Connect

App Store Connect → App Information → General Information → Bundle ID (shows full App ID)

The Team ID is the prefix before the bundle identifier

Host the File

Hosting Requirements
  • • Host at: https://yourdomain.com/.well-known/apple-app-site-association
  • • Also accessible at: https://yourdomain.com/apple-app-site-association
  • • Serve with Content-Type: application/json
  • • Must be accessible via HTTPS
  • • No file extension (.json) needed
  • • No redirects allowed
  • • Include proper caching headers (max-age=3600)

Web Credentials (Optional)

Web credentials enable password AutoFill between your website and iOS app, creating a seamless authentication experience.

Benefits
  • • Automatic password suggestions from Keychain
  • • Seamless login experience across web and app
  • • Shared authentication state
  • • Enhanced security with verified domains

4. Handle URLs in SwiftUI

Basic URL Handling (SwiftUI)

ContentView.swift
import SwiftUI

struct ContentView: View {
    @StateObject private var navigationManager = NavigationManager()
    
    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            HomeView()
                .navigationDestination(for: String.self) { route in
                    destinationView(for: route)
                }
        }
        .onOpenURL { url in
            handleUniversalLink(url)
        }
    }
    
    private func handleUniversalLink(_ url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return
        }
        
        let path = components.path
        let queryItems = components.queryItems
        
        switch path {
        case let p where p.hasPrefix("/product"):
            if let productId = queryItems?.first(where: { $0.name == "id" })?.value {
                navigationManager.navigateToProduct(id: productId)
            }
            
        case "/dashboard":
            navigationManager.navigateToDashboard()
            
        case let p where p.hasPrefix("/profile"):
            if let userId = queryItems?.first(where: { $0.name == "user" })?.value {
                navigationManager.navigateToProfile(userId: userId)
            }
            
        default:
            print("Unknown path: \(path)")
        }
    }
    
    @ViewBuilder
    private func destinationView(for route: String) -> some View {
        let components = route.components(separatedBy: ":")
        let destination = components[0]
        
        switch destination {
        case "product":
            if components.count > 1 {
                ProductDetailView(productId: components[1])
            }
        case "dashboard":
            DashboardView()
        case "profile":
            if components.count > 1 {
                ProfileView(userId: components[1])
            }
        default:
            Text("Unknown destination")
        }
    }
}

Navigation Manager

NavigationManager.swift
import SwiftUI

@MainActor
class NavigationManager: ObservableObject {
    @Published var path = NavigationPath()
    
    func navigateToProduct(id: String) {
        // Validate product ID
        guard isValidProductId(id) else {
            print("Invalid product ID: \(id)")
            return
        }
        path.append("product:\(id)")
    }
    
    func navigateToDashboard() {
        path.append("dashboard")
    }
    
    func navigateToProfile(userId: String) {
        // Validate user ID
        guard isValidUserId(userId) else {
            print("Invalid user ID: \(userId)")
            return
        }
        path.append("profile:\(userId)")
    }
    
    func popToRoot() {
        path.removeLast(path.count)
    }
    
    // MARK: - Validation
    
    private func isValidProductId(_ id: String) -> Bool {
        // Validate product ID format
        let regex = "^[a-zA-Z0-9_-]{1,50}$"
        return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: id)
    }
    
    private func isValidUserId(_ id: String) -> Bool {
        // Validate user ID format
        let regex = "^[a-zA-Z0-9_-]{1,30}$"
        return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: id)
    }
}

UIKit App Delegate Approach

AppDelegate.swift
import UIKit

class AppDelegate: UIResponder, UIApplicationDelegate {
    
    func application(_ application: UIApplication, 
                     continue userActivity: NSUserActivity, 
                     restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
        
        guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
              let url = userActivity.webpageURL else {
            return false
        }
        
        // Handle the Universal Link
        return handleUniversalLink(url)
    }
    
    private func handleUniversalLink(_ url: URL) -> Bool {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            return false
        }
        
        let path = components.path
        
        // Post notification for SwiftUI to handle
        NotificationCenter.default.post(
            name: .universalLinkReceived, 
            object: url
        )
        
        return true
    }
}

extension Notification.Name {
    static let universalLinkReceived = Notification.Name("universalLinkReceived")
}

🔒 Security Considerations

Input Validation

Critical

Always validate incoming URL parameters to prevent security vulnerabilities.

private func handleUniversalLink(_ url: URL) {
    guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
        return
    }
    
    let path = components.path
    
    // ❌ Bad: Direct usage without validation
    // let productId = queryItems?.first(where: { $0.name == "id" })?.value
    // navigationManager.navigateToProduct(id: productId!)
    
    // ✅ Good: Validate input first
    guard isValidPath(path) else {
        print("Invalid path: \(path)")
        return
    }
    
    if let productId = queryItems?.first(where: { $0.name == "id" })?.value {
        guard isValidProductId(productId) else {
            print("Invalid product ID: \(productId)")
            return
        }
        navigationManager.navigateToProduct(id: productId)
    }
}

private func isValidPath(_ path: String) -> Bool {
    let allowedPaths = ["/product", "/dashboard", "/profile"]
    return allowedPaths.contains { path.hasPrefix($0) }
}

private func isValidProductId(_ id: String) -> Bool {
    let regex = "^[a-zA-Z0-9_-]{1,50}$"
    return NSPredicate(format: "SELF MATCHES %@", regex).evaluate(with: id)
}

Authentication & Authorization

Important
  • • Always verify user authentication before accessing sensitive content
  • • Validate user permissions for accessed resources
  • • Don't expose sensitive data through URL parameters
  • • Use secure tokens for authenticated deep links
  • • Implement rate limiting for URL processing

Best Security Practices

HTTPS Only

Universal Links only work with HTTPS, providing built-in security against man-in-the-middle attacks.

Domain Verification

iOS automatically verifies domain ownership through the apple-app-site-association file.

Graceful Fallbacks

Always handle invalid URLs gracefully without exposing sensitive information or crashing.

Path Restrictions

Use specific path patterns in apple-app-site-association to limit which URLs open your app.

6. Testing & Debugging

Testing Methods

1. Safari Testing

Open Safari on your device and navigate to your Universal Link URL.

https://yourdomain.com/product?id=123

2. Notes App Testing

Paste your Universal Link in the Notes app and tap it.

Open Notes → Type URL → Tap the link

3. Messages Testing

Send yourself a message with the Universal Link and tap it.

iMessage → Send link to yourself → Tap

Debugging Tools

Apple App Site Association Validator

Use Apple's official validator:

search.developer.apple.com/appsearch-validation-tool/

Console Debugging

Add logging to track URL handling:

print("Received URL: \\(url)")

Device Console

Connect device to Xcode and check console logs for Universal Link processing.

Network Inspector

Verify iOS can fetch your apple-app-site-association file over HTTPS.

Testing Checklist

  • ✓ apple-app-site-association file is accessible via HTTPS
  • ✓ Associated Domains capability is properly configured
  • ✓ App ID matches the one in apple-app-site-association
  • ✓ URL handling code is implemented and tested
  • ✓ Links work on physical device (not simulator)
  • ✓ Links work from different apps (Safari, Messages, Notes)
  • ✓ Fallback to website works when app isn't installed

7. Common Issues & Solutions

❌ Universal Links open Safari instead of app

  • • Check apple-app-site-association file is accessible and valid
  • • Verify Team ID and Bundle ID match exactly
  • • Ensure Associated Domains capability is properly configured
  • • Test on physical device (Universal Links don't work in Simulator)
  • • Check that the URL path is included in the "paths" array

⚠️ Apple-app-site-association file not found

  • • Ensure file is at /.well-known/apple-app-site-association
  • • Check file has no .json extension
  • • Verify Content-Type: application/json header
  • • Test file accessibility in browser
  • • Ensure no redirects are happening

ℹ️ Universal Links work inconsistently

  • • iOS caches Universal Link settings - uninstall and reinstall app
  • • Universal Links don't work when tapped within your own app
  • • Long-pressing a link gives option to open in app or Safari
  • • User may have manually chosen to open in Safari (check Settings)

✅ Best Practices

  • • Always test on physical devices, not simulators
  • • Use Apple's validation tool for apple-app-site-association
  • • Implement proper fallback handling for unrecognized URLs
  • • Keep apple-app-site-association file simple and focused
  • • Test Universal Links from multiple sources (Safari, Messages, etc.)

📱 Example iOS App

Want to see a working implementation? Check out our complete iOS Universal Links example application with full source code.

iOS Universal Links Example

Complete working iOS application demonstrating Universal Links implementation with SwiftUI and proper fallback handling.

Universal Links (https://example.deeptap.io/{path})
SwiftUI navigation with NavigationStack
Complete apple-app-site-association configuration
Graceful fallback handling for users without the app
View on GitHubSwift • Open Source
Quick Start

1. Clone the repository: git clone https://github.com/TedyHub/ios_universal_links.git

2. Open in Xcode and configure your Team ID and Bundle Identifier

3. Build and run on a physical device to test Universal Links

🎉 You're All Set!

Your iOS app should now handle Universal Links seamlessly. Test thoroughly on physical devices and various scenarios.