Skip to content
Tolinku
Tolinku
Sign In Start Free
Deep Linking · · 9 min read

How to Implement Deep Links from Scratch

By Tolinku Staff
|
Tolinku industry trends dashboard screenshot for deep linking blog posts

Deep links let a URL open a specific screen inside your app instead of just launching the home screen. A user taps a link in an email, a text message, or a web page, and your app opens directly to the right product, profile, or piece of content. No detour through the home screen. No "please navigate to…" instructions.

This tutorial walks through the full implementation: planning your URL structure, configuring iOS Universal Links, configuring Android App Links, writing route handlers in Swift and Kotlin, and dealing with the edge cases that catch most developers off guard.

If you want a higher-level overview of what deep links actually are and why they matter, start with our guide on how deep linking works or the deep linking concepts guide before continuing here.

Step 1: Plan Your URL Structure

Before touching Xcode or Android Studio, decide what your URLs will look like. This matters more than most developers expect. A URL structure is hard to change once it is in the wild, and a poorly designed one leads to fragile routing logic.

Good deep link URLs follow the same conventions as good web URLs:

  • Keep paths short and readable
  • Use nouns, not verbs (/products/123, not /view-product?id=123)
  • Separate concerns clearly (user profiles at /u/:username, products at /products/:id)
  • Avoid query strings for primary routing; use them for optional context like referral codes or campaign tracking

A simple example schema:

https://yourapp.com/products/:productId
https://yourapp.com/u/:username
https://yourapp.com/orders/:orderId
https://yourapp.com/invite/:code

Write these down before you write a single line of handler code. You will reference them throughout the implementation.

Universal Links are Apple's mechanism for opening your app from a standard HTTPS URL. When iOS sees a URL that matches your app's registered domains, it opens your app instead of Safari. If the app is not installed, it falls back to the web URL.

2a. Create the Apple App Site Association File

The AASA file is a JSON document hosted at the root of your domain. iOS fetches it when your app is installed, then uses it to decide which URL paths to intercept. For a detailed walkthrough of the AASA file format and common mistakes, see our AASA file setup guide.

Create a file called apple-app-site-association (no file extension) with this structure:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.yourcompany.yourapp"],
        "components": [
          {
            "/": "/products/*",
            "comment": "Product detail pages"
          },
          {
            "/": "/u/*",
            "comment": "User profiles"
          },
          {
            "/": "/orders/*",
            "comment": "Order details"
          },
          {
            "/": "/invite/*",
            "comment": "Invite links"
          }
        ]
      }
    ]
  }
}

Replace TEAMID with your Apple Developer Team ID, and update the bundle identifier to match your app.

Host this file at:

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

Both paths work. Apple checks both. The file must be served over HTTPS with a Content-Type of application/json. No redirects. Apple's CDN fetches this file and caches it, so changes can take time to propagate. You can verify your file using Apple's AASA validator.

2b. Enable Associated Domains in Xcode

Open your project in Xcode and go to your app target's Signing & Capabilities tab. Click + Capability and add Associated Domains.

Add your domain with the applinks: prefix:

applinks:yourapp.com

If you use subdomains, add each one separately:

applinks:www.yourapp.com
applinks:yourapp.com

This entitlement gets embedded in your app binary and tells iOS which domains to check for an AASA file.

2c. Handle Incoming URLs in Swift

iOS delivers Universal Links to your app through UIApplicationDelegate or SceneDelegate. The entry point is application(_:continue:restorationHandler:).

For a UIKit app:

import UIKit

@main
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
        }
        return DeepLinkRouter.shared.handle(url: url)
    }
}

For a SwiftUI app using the onOpenURL modifier:

@main
struct YourApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    DeepLinkRouter.shared.handle(url: url)
                }
        }
    }
}

The DeepLinkRouter is where your actual routing logic lives. We will build that in Step 4.

Android App Links use a similar pattern: a JSON verification file hosted on your server, a manifest configuration, and a handler in your Activity.

Create a file called assetlinks.json (see our complete Digital Asset Links setup guide for details) and host it at:

https://yourapp.com/.well-known/assetlinks.json

The file format looks like this:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.yourcompany.yourapp",
      "sha256_cert_fingerprints": [
        "AA:BB:CC:DD:EE:FF:..."
      ]
    }
  }
]

To get your SHA-256 certificate fingerprint from your keystore:

keytool -list -v -keystore your-release.keystore -alias your-alias

For debug builds, Android Studio generates a debug keystore automatically. You can get its fingerprint with:

keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android

You can verify your setup using the Android Asset Links validator.

3b. Configure Your Android Manifest

In AndroidManifest.xml, add an intent filter to the Activity that should handle deep links:

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTask">

    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data
            android:scheme="https"
            android:host="yourapp.com" />
    </intent-filter>

</activity>

The android:autoVerify="true" attribute is what makes these App Links rather than plain deep links. Android will verify your domain ownership through the assetlinks.json file before granting automatic link handling. Without it, Android shows a disambiguation dialog asking the user which app to open the link in.

3c. Handle Incoming Intents in Kotlin

In your MainActivity, handle the incoming intent in onCreate and onNewIntent:

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        intent?.let { handleIntent(it) }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        handleIntent(intent)
    }

    private fun handleIntent(intent: Intent) {
        if (intent.action == Intent.ACTION_VIEW) {
            intent.data?.let { uri ->
                DeepLinkRouter.handle(uri, this)
            }
        }
    }
}

onNewIntent fires when the Activity is already running and a new intent arrives (because of singleTask launch mode). You need both handlers or you will miss links that arrive while the app is open.

Step 4: Build a Simple Router

Both platforms now call into a router. This is where your URL paths map to screens.

Swift Router

import UIKit

final class DeepLinkRouter {

    static let shared = DeepLinkRouter()
    private init() {}

    @discardableResult
    func handle(url: URL) -> Bool {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            return false
        }

        let pathComponents = components.path
            .split(separator: "/")
            .map(String.init)

        switch pathComponents.first {
        case "products":
            if let productId = pathComponents.dropFirst().first {
                navigateToProduct(id: productId)
                return true
            }
        case "u":
            if let username = pathComponents.dropFirst().first {
                navigateToProfile(username: username)
                return true
            }
        case "invite":
            if let code = pathComponents.dropFirst().first {
                navigateToInvite(code: code)
                return true
            }
        default:
            break
        }

        return false
    }

    private func navigateToProduct(id: String) {
        // Push ProductViewController onto the navigation stack
    }

    private func navigateToProfile(username: String) {
        // Push ProfileViewController onto the navigation stack
    }

    private func navigateToInvite(code: String) {
        // Present InviteViewController
    }
}

Kotlin Router

import android.content.Context
import android.net.Uri

object DeepLinkRouter {

    fun handle(uri: Uri, context: Context): Boolean {
        val segments = uri.pathSegments

        if (segments.isEmpty()) return false

        return when (segments[0]) {
            "products" -> {
                val productId = segments.getOrNull(1) ?: return false
                navigateToProduct(productId, context)
                true
            }
            "u" -> {
                val username = segments.getOrNull(1) ?: return false
                navigateToProfile(username, context)
                true
            }
            "invite" -> {
                val code = segments.getOrNull(1) ?: return false
                navigateToInvite(code, context)
                true
            }
            else -> false
        }
    }

    private fun navigateToProduct(id: String, context: Context) {
        // Start ProductActivity with productId as an extra
    }

    private fun navigateToProfile(username: String, context: Context) {
        // Start ProfileActivity with username as an extra
    }

    private fun navigateToInvite(code: String, context: Context) {
        // Start InviteActivity with code as an extra
    }
}

Keep the router lean. It should parse, match, and delegate. Business logic belongs in the destination screens.

Step 5: Handle Edge Cases

The happy path is straightforward. The edge cases are where most implementations break.

App Not Installed

When iOS or Android cannot open your app (because it is not installed), the fallback is the web URL. This means your web server needs to handle every deep link path gracefully. At minimum, serve a page with an App Store or Play Store link. Better yet, use that web page to save the deep link destination and resume navigation after install. This is called deferred deep linking, and it requires a server-side component to work correctly. Our guide on building deferred deep links covers the implementation in detail.

Tolinku handles deferred deep linking automatically: the install destination is stored server-side and delivered to the app on first open, so users land exactly where they intended even after a fresh install.

Invalid or Expired Paths

Not every URL your router receives will be valid. Product IDs get deleted. Invite codes expire. Order IDs belong to other users. Handle these cases explicitly rather than crashing or showing a blank screen.

In Swift:

private func navigateToProduct(id: String) {
    guard !id.isEmpty else {
        navigateToHome()
        return
    }
    // Proceed with navigation, handle 404 in the product screen itself
}

Return the user to a sensible fallback screen rather than leaving them on a broken state. The product screen itself should handle the case where the API returns a 404.

State Restoration Timing

On iOS, Universal Links delivered at cold launch arrive before your root view controller is fully set up. If you push a view controller immediately in application(_:continue:restorationHandler:), you may push onto nothing.

A clean pattern is to store the pending URL and process it after the UI is ready:

class AppDelegate: UIResponder, UIApplicationDelegate {
    var pendingDeepLink: URL?

    func application(
        _ application: UIApplication,
        continue userActivity: NSUserActivity,
        restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
    ) -> Bool {
        guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
              let url = userActivity.webpageURL else { return false }
        pendingDeepLink = url
        return true
    }
}

Then in your root view controller's viewDidAppear:

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    if let url = (UIApplication.shared.delegate as? AppDelegate)?.pendingDeepLink {
        DeepLinkRouter.shared.handle(url: url)
        (UIApplication.shared.delegate as? AppDelegate)?.pendingDeepLink = nil
    }
}

Step 6: Test on Both Platforms

Testing deep links requires real devices or simulators, not just unit tests. For a comprehensive look at available tools and approaches, see our deep link testing tools roundup.

Testing on iOS

From Terminal, use xcrun simctl to simulate a Universal Link opening in the simulator:

xcrun simctl openurl booted "https://yourapp.com/products/abc123"

For a device, use the Notes app: type the URL, long-press it, and tap "Open". Safari will route it through Universal Links.

To test cold launch (app not running), terminate the app first, then use openurl. Watch the console output in Xcode for any routing errors.

Testing on Android

Use adb to send an intent to your app:

adb shell am start \
  -W -a android.intent.action.VIEW \
  -d "https://yourapp.com/products/abc123" \
  com.yourcompany.yourapp

Add the -n flag to target a specific activity if needed:

adb shell am start \
  -W -a android.intent.action.VIEW \
  -d "https://yourapp.com/products/abc123" \
  -n com.yourcompany.yourapp/.MainActivity

Test with the app in the background (press home, then run the command) and with the app fully closed. Both scenarios exercise different code paths.

On Android, you can check whether domain verification succeeded:

adb shell pm get-app-links com.yourcompany.yourapp

Look for verified in the output. If it shows none or ask, check that your assetlinks.json is reachable and correctly formatted.

Where to Go Next

This implementation covers the fundamentals: URL structure, platform configuration files, Swift and Kotlin handlers, routing logic, and common edge cases. From here, the natural extensions are deferred deep linking (handling installs mid-flow), analytics on which links are driving conversions, and smart banners that surface your app to web visitors without being intrusive.

The Tolinku quick start guide covers how to connect this foundation to a hosted routing and analytics layer so you get click data, attribution, and deferred linking without building server infrastructure yourself.

For reference, the deep linking features overview covers what is possible beyond basic URL routing.

The implementation above is enough to get links working in a production app. The edge cases in Step 5 are what separate a working implementation from a reliable one. Handle those and your users will have a consistent experience regardless of whether they have the app installed or which screen the link is meant to reach.

Get deep linking tips in your inbox

One email per week. No spam.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.