Home | Resources | Blog

Branch Deep Linking Explained: Best Practices for Routing Users

Rob Gioia

Rob Gioia

PUBLISHED: LAST REVISED:

Deep links take users to a specific location within a downloaded app, so they don’t have to manually search for content they want. By directing users to in-app content whenever they click a link, deep links remove friction from the user journey and help brands personalize user experiences, optimize app conversions, and improve app retention and engagement rates.

Branch deep links work across devices and platforms, including mobile web, email, and offline channels. With the Branch SDK and the best practices in this post, you can drive customers to take action in your mobile app while collecting useful data about your users and campaigns. 

What is the role of the Branch SDK in deep link routing?

(Note: This section assumes you the Branch SDK installed in your app. If you don’t, follow the steps linked here before proceeding. For a high-level overview of deep links, check out the Branch deep linking guide.)

When a user clicks a Branch link to open the app, the Branch SDK will pass along any data you attached to the link. You can access the data anytime after the app is opened, but it is up to you to utilize it to route the user to the content of interest. We’ll cover how to do this step-by-step in the following sections.

There are unlimited properties you can use to pass deep link data to the Branch SDK, but unless you have a custom setup, we recommend $canonical_url or $deeplink_path. The right choice depends on your mobile/web/UX needs.

If your app has parity with your website

If your website serves as the mobile web version of your app for users who don’t have your app installed, your app has web-to-app parity. In this case, use $canonical_url, which is a property you set in the link data that corresponds to the web link.

If you enter a URL in the original URL field when creating a Quick Link, Branch automatically populates the $canonical_url. When the app opens, you can parse that link and use it to deep link the user to the right app content. For example, we have a site hosting various Branch monsters here: https://monster-site.github.io/shop/items.html. 

Each monster links to its own detail page on the web. Click Astrocreep, for example, and the site opens its detail screen.

The URL has a query parameter with a key of id and a value of zero: https://monster-site.github.io/shop/item-detail.html?id=0. When we create a Quick Link on the Branch Dashboard, we can paste the full link into the Original URL field.

Screenshot of a Branch Dashboard showing the Original Web URL field with https://monster-site.github.io/shop/item-detail.html?id=0 populated.

Branch automatically populates the $canonical_url value in the link data for this link:

Screenshot of the Key and Values field in the Branch Dashboard.

When a user clicks this Branch link, the Branch SDK surfaces the $canonical_url value, and you can use it to route the user to Astrocreep’s detail screen in the app. Here, we take the $canonical_url value from the Branch link data, https://monster-site.github.io/shop/item-detail.html?id=0, and start by creating a URL object from it. Examples by platform are below.

See more info on $canonical_url in our help docs.

Kotlin
fun deepLinkUsingCanonicalURL(canonicalURL : String) {
   val url = URL(canonicalURL)
Swift
func deepLinkUsingCanonicalURL(canonicalURL: String) {
   guard let url = URL(string: canonicalURL) else { return }
Dart / Flutter
void deepLinkUsingCanonicalURL(String canonicalURL) {
   final url = Uri.parse(canonicalURL);
TypeScript
const deepLinkUsingCanonicalURL = (canonicalURL: string) => {
   const url = new URL(canonicalURL);

Next, we check the URL’s path. For our example link, the path is /shop/item-detail.html. Depending on how your website is set up, you may not need the “.html” file extension at the end of the path.

Kotlin
when(url.path) {
   "/shop/items.html" -> {
       navigationUtils.loadShopScreen()
   }
   "/shop/item-detail.html" -> {
       val id = getIdFromQueryParams(canonicalURL)
       navigationUtils.loadShopScreen(id)
   }
Swift
switch url.path {
case "/shop/items.html":
   navigationUtils.loadShopScreen()
case "/shop/item-detail.html":
   let id = getIdFromQueryParams(url: canonicalURL)
   navigationUtils.loadShopScreen(id: id ?? "")
default:
   break
}
Dart / Flutter
switch (url.path) {
   case "/shop/items.html":
      navigationUtils.loadShopScreen();
   case "/shop/item-detail.html":
      final id = getIdFromQueryParams(canonicalURL);
      navigationUtils.loadShopScreen(id);
}
TypeScript
switch (url.pathname) {
   case "/shop/items.html":
      navigationUtils.loadShopScreen();
      break;
   case "/shop/item-detail.html":
   const id = getIdFromQueryParams(canonicalURL);
   navigationUtils.loadShopScreen(id);
   break;
}

When the code hits the matching case, it determines which item’s detail page to open, then uses the id to route the user to Astrocreep’s page in the mobile app.

Screenshot of the Astrocreep details page on the Branch MonsterSite shown on a phone.

In short, use $canonical_url to route users to the proper in-app content whenever the user can access the same content via your app and website. 

If your app does not have web-to-app parity

If the content you want to route users to exists in your app but not on your website, use the $deeplink_path property from the link data. In this example, we’ll navigate to another monster from the sample, Starbeast.

Screenshot of the Starbeast detail page in the Branch Monster Factory app.

When you create a Quick Link or Banner, add $deeplink_path as a key and set its value to the relevant deep link path. While $canonical_url holds the full URL to the content, $deeplink_path holds a path to the content with values separated by forward slashes.

This example code checks whether the deep link path contains the substring “shop” (/shop/item-detail?id=1). See more on $deeplink_path in our help docs.

Kotlin
fun deepLinkUsingDeepLinkPath(deepLinkPath : String) {
   if(deepLinkPath.contains("shop")) {
Swift
func deepLinkUsingDeepLinkPath(deepLinkPath: String) {
   if deepLinkPath.contains("shop") {
Dart / Flutter
void deepLinkUsingDeepLinkPath(String deepLinkPath) {
   if (deepLinkPath.contains("shop")) {
TypeScript
const deepLinkUsingDeepLinkPath = (deepLinkPath: string) => {
   if (deepLinkPath.includes("shop")) {

Then we check whether the deep link path contains “item-detail.” If it does, we load that item’s detail view directly. In our example, the id value is “1” (/shop/item-detail?id=1).

Kotlin
if(deepLinkPath.contains("item-detail")) {
   val id = getIdFromQueryParams(deepLinkPath)
   navigationUtils.loadShopScreen(id.toString())
} else {
   navigationUtils.loadShopScreen()
}
Swift
if deepLinkPath.contains("item-detail") {
   let id = getIdFromQueryParams(url: deepLinkPath)
   navigationUtils.loadShopScreen(id: id ?? "")
} else {
   navigationUtils.loadShopScreen()
}
Dart / Flutter
if (deepLinkPath.contains("item-detail")) {
   final id = getIdFromQueryParams(deepLinkPath);
   navigationUtils.loadShopScreen(id.toString());
} else {
   navigationUtils.loadShopScreen();
}
TypeScript
if (deepLinkPath.includes("item-detail")) {
   const id = getIdFromQueryParams(deepLinkPath);
   navigationUtils.loadShopScreen(String(id));
} else {
   navigationUtils.loadShopScreen();
}

The code loads the shop screen at the intended id, which opens the Starbeast detail screen in the app.

Screenshot of the Starbeast detail page in the Branch Monster Factory app.

If you’re wondering what the getIdFromQueryParams function looks like, here it is:

Kotlin
fun getIdFromQueryParams(url : String) : String {
   val urlQuerySanitizer = UrlQuerySanitizer()
   urlQuerySanitizer.allowUnregisteredParamaters = true
   urlQuerySanitizer.parseUrl(url)
   val id = urlQuerySanitizer.getValue("id")
   return id
}
Swift
func getIdFromQueryParams(url: String) -> String? {
    guard let urlObj = URLComponents(string: url) else { return nil }
    return urlObj.queryItems?.first(where: { $0.name == "id" })?.value
}
Dart / Flutter
String? getIdFromQueryParams(String urlString) {
  final url = Uri.parse(urlString);
  return url.queryParameters["id"];
}
TypeScript
const getIdFromQueryParams = (urlString: string): string | null => {
   const safeUrl = urlString.startsWith('http')
     ? urlString
     : `https://dummy.com${urlString.startsWith('/') ? '' : '/'}${urlString}`;
   const url = new URL(safeUrl);
   return url.searchParams.get("id");
};

The ID lives in a query parameter, so each version parses the URL and pulls the id value. On Android, the UrlQuerySanitizer class handles that mapping.

In short, use $deeplink_path to route users to the proper in-app content whenever the content is present on your app but not your website.

$canonical_url

$deeplink_path

A full web URL

e.g. https://monster-site.github.io/shop/item-detail.html?id=0

Use when the content is present on your app AND website (web-to-app parity)

A path of values separated by forward slashes

e.g. /shop/item-detail/1

Use when the content is present on your app but NOT your website (not parity)

Using Branch alongside existing deep linking logic

If you already have a deep linking solution in place, whether native logic or another third-party SDK, you may wonder whether you can also use Branch. You can, and many customers who work with our Professional Services team run exactly this setup. Here’s how it works.

Whenever the app opens, the Branch SDK gets initialized. The +clicked_branch_link property will have a value of “true” if a Branch link click opens the app. If another type of link was used to open the app, or the app was opened organically (i.e. without a link click), then +clicked_branch_link will have a value of “false” and +non_branch_link will have a value of the link used to open the app. You can use +clicked_branch_link in a conditional statement to decide whether to handle deep linking through Branch or through your existing routing logic.

Kotlin
if (linkProperties?.has("+clicked_branch_link") == true &&
    linkProperties.get("+clicked_branch_link") as Boolean) {
    // A Branch link was clicked, handle deep linking via Branch
} else {
    // The app opened another way, handle non-branch deep linking
}
Swift
if let clickedBranchLink = linkProperties?["+clicked_branch_link"] as? Bool,
   clickedBranchLink {
    // A Branch link was clicked, handle deep linking via Branch
} else {
    // The app opened another way, handle non-branch deep linking
}
Dart / Flutter
if (linkProperties != null &&
    linkProperties.containsKey("+clicked_branch_link") &&
    linkProperties["+clicked_branch_link"] == true) {
  // A Branch link was clicked, handle deep linking via Branch
} else {
  // The app opened another way, handle non-branch deep linking
}
TypeScript
branch.subscribe({
  onOpenComplete: ({ error, params, uri }) => {
     if (error) {
       console.error('Branch error: ' + error);
       return;
     }
     if (params?.['+clicked_branch_link']) {
       // A Branch link was clicked, handle deep linking via Branch
     } else {
       // The app opened another way, handle non-branch deep linking
     }
  },
});

Pro-tip: If your app uses existing deep link routing logic, Branch can work alongside that, and you can check the value of +clicked_branch_link to determine whether a Branch link facilitated the app opening. 

App open via Branch link

Other app open

+clicked_branch_link is true

+non_branch_link is null

Route to content using the Branch link data

+clicked_branch_link is false

+non_branch_link will have the value of the link that was clicked (or be null if the app was opened organically)

Route to content using your existing deep link logic

Linking to web content with Branch links

Sometimes you want a Branch link to send users to the web instead of your app. An unsubscribe link in an email, for example, shouldn’t open your app. For links that should only go to the web, Branch provides the $web_only parameter. Add it to a link as a query parameter with a value of “true,” or check the Web-Only Link box when creating a Quick Link.

Under the hood, this adds “/e” to the URL path (https://branchster-web.app.link/e/ex123). On iOS, users who click the link go straight to the web because the link no longer matches the paths listed in the AASA file. However, if the link sits in an email or the user is on Android, the app still opens by default, so you’ll need additional code to check for the parameter and route the user to mobile web. Check the link data for $web_only with a value of “true.” If it’s there, send the user to mobile web. If not, continue with your normal Branch deep link routing.

Kotlin
if(linkProperties?.has("$web_only") == true && 
linkProperties.get("$web_only") as Boolean) {
   //A web-only link was clicked, route the user to the web
} else {
   //Handle deep link routing from the Branch-link click
}
Swift
if let webOnly = linkProperties?["$web_only"] as? Bool, webOnly {
   // A web-only link was clicked, route the user to the web
} else {
   // Handle deep link routing from the Branch-link click
}
Dart / Flutter
if (linkProperties != null &&
    linkProperties.containsKey("\$web_only") &&
    linkProperties["\$web_only"] == true) {
  // A web-only link was clicked, route the user to the web
} else {
  // Handle deep link routing from the Branch-link click
}
TypeScript
branch.subscribe({
  onOpenComplete: ({ error, params, uri }) => {
    if (error) return;

    if (params?.['$web_only']) {
      // A web-only link was clicked, route the user to the web
    } else {
      // Handle deep link routing from the Branch-link click
    }
  },
});

Pro-tip: It is possible to have some Branch links only route the user to mobile web content by using the $web-only parameter. You’ll need to add code to check for this parameter and route the user to the content on the mobile web if it is set to a value of true. 

Android web-only links

iOS web-only links

App will open first, check for the $web-only parameter and if it is present and has a value of true, route the user to the mobile web

Web-only links created from the dashboard will have a /e in the path thus causing iOS to open the web

If the link is present in an email, it is click-wrapped so it will open the app first and you will need to check for the $web-only parameter and, if it is present and has a value of true, route the user to the mobile web

Deep link routing best practices

Keep these best practices in mind as you build your deep link routing logic with the Branch SDK:

  1. Use $canonical_url as the parameter for routing whenever possible. This parameter automatically gets populated for Banners and can also be used with Branch Universal Objects to attribute app clicks back to the web content to increase its SEO ranking. 
  2. Access Branch deep link data anytime during the app session. Every Branch SDK can retrieve the latest referring parameters from the most recent Branch link click, so you can build tailored experiences like deep linking a user after onboarding.
  3. Fall back to the homescreen when a deep link fails. Don’t leave your users hanging on an infinite spinner or frozen screen. Add logic to fall back to the homescreen when the app doesn’t recognize a deep link.

Build these practices into your routing logic, and users land on the content they came for. Want help cleaning up broken links in your app? Talk to our team.

Full source code

Kotlin
package com.monster.monsterapp

import android.content.Intent
import android.net.UrlQuerySanitizer
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import io.branch.referral.Branch
import java.net.URL

class MainActivity : AppCompatActivity() {

    private lateinit var navigationUtils: NavigationUtils

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        navigationUtils = NavigationUtils(this)
    }

    override fun onStart() {
        super.onStart()
        Branch.sessionBuilder(this).withCallback { linkProperties, error ->
            if (error == null && linkProperties != null) {
                if (linkProperties.optBoolean("+clicked_branch_link", false)) {
                    if (linkProperties.optBoolean("$web_only", false)) {
                         // Route user to web
                         return@withCallback
                    }
                    val canonicalUrl = linkProperties.optString("$canonical_url", "")
                    val deepLinkPath = linkProperties.optString("$deeplink_path", "")
                    if (canonicalUrl.isNotEmpty()) {
                         deepLinkUsingCanonicalURL(canonicalUrl)
                    } else if (deepLinkPath.isNotEmpty()) {
                         deepLinkUsingDeepLinkPath(deepLinkPath)
                    }
                } else {
                    // Handle non-branch deep linking
                }
            }
        }.withData(this.intent.data).init()
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        this.intent = intent
        if (intent.hasExtra("branch_force_new_session") &&
            intent.getBooleanExtra("branch_force_new_session", false)) {
            Branch.sessionBuilder(this).withCallback { _, _ -> }.reInit()
        }
    }
}
Swift
import SwiftUI
import BranchSDK

class AppDelegate: NSObject, UIApplicationDelegate {

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        Branch.getInstance().initSession(launchOptions: launchOptions) { params, error in
            guard error == nil, let linkProperties = params as? [String: Any] else { return }

            if let clickedBranchLink = linkProperties["+clicked_branch_link"] as? Bool,
               clickedBranchLink {

                if let webOnly = linkProperties["$web_only"] as? Bool, webOnly {
                    // Route user to web
                    return
                }

                if let canonicalUrl = linkProperties["$canonical_url"] as? String,
                   !canonicalUrl.isEmpty {
                     self.deepLinkUsingCanonicalURL(canonicalURL: canonicalUrl)
                } else if let deepLinkPath = linkProperties["$deeplink_path"] as? String,
                           !deepLinkPath.isEmpty {
                     self.deepLinkUsingDeepLinkPath(deepLinkPath: deepLinkPath)
                }
            } else {
                // Handle non-branch deep linking
            }
        }
        return true
    }

    // MARK: - Routing

    private func deepLinkUsingCanonicalURL(canonicalURL: String) {
        guard let url = URL(string: canonicalURL) else { return }
        switch url.path {
        case "/shop/items.html":
            navigationUtils.loadShopScreen()
        case "/shop/item-detail.html":
            let id = getIdFromQueryParams(url: canonicalURL)
            navigationUtils.loadShopScreen(id: id ?? "")
        default:
            break
        }
    }

    private func deepLinkUsingDeepLinkPath(deepLinkPath: String) {
        if deepLinkPath.contains("shop") {
            if deepLinkPath.contains("item-detail") {
                let id = getIdFromQueryParams(url: deepLinkPath)
                navigationUtils.loadShopScreen(id: id ?? "")
            } else {
                navigationUtils.loadShopScreen()
            }
        }
    }

    private func getIdFromQueryParams(url: String) -> String? {
        guard let urlObj = URLComponents(string: url) else { return nil }
        return urlObj.queryItems?.first(where: { $0.name == "id" })?.value
    }

    private let navigationUtils = NavigationUtils()
}

@main
struct MyApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    // Handles Universal Links / custom-scheme opens while the app is running
                    Branch.getInstance().handleDeepLink(url)
                }
            }
        }
}
Dart / Flutter
import 'package:flutter/material.dart';
import 'package:flutter_branch_sdk/flutter_branch_sdk.dart';

void main() => runApp(const MyApp());

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

    @override
    _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final NavigationUtils navigationUtils = NavigationUtils();

    @override
    void initState() {
      super.initState();
      listenDynamicLinks();
    }

    void listenDynamicLinks() async {
      FlutterBranchSdk.listSession().listen((data) {
        if (data.containsKey("+clicked_branch_link") &&
            data["+clicked_branch_link"] == true) {
          if (data.containsKey("\$web_only") && data["\$web_only"] == true) {
            // Route to web
            return;
          }
          final canonicalUrl = data["\$canonical_url"] as String?;
          final deeplinkPath = data["\$deeplink_path"] as String?;

            if (canonicalUrl != null && canonicalUrl.isNotEmpty) {
              deepLinkUsingCanonicalURL(canonicalUrl);
            } else if (deeplinkPath != null && deeplinkPath.isNotEmpty) {
              deepLinkUsingDeepLinkPath(deeplinkPath);
            }
          } else {
            // Handle non-branch deep linking
          }
        }, onError: (error) {
          // Handle error
        });
    }

    void deepLinkUsingCanonicalURL(String canonicalURL) {
      final url = Uri.parse(canonicalURL);
      switch (url.path) {
        case "/shop/items.html":
          navigationUtils.loadShopScreen();
        case "/shop/item-detail.html":
          navigationUtils.loadShopScreen(getIdFromQueryParams(canonicalURL));
      }
    }

    void deepLinkUsingDeepLinkPath(String deepLinkPath) {
      if (deepLinkPath.contains("shop")) {
        if (deepLinkPath.contains("item-detail")) {
          final id = getIdFromQueryParams(deepLinkPath);
          navigationUtils.loadShopScreen(id.toString());
        } else {
          navigationUtils.loadShopScreen();
        }
      }
    }
    String? getIdFromQueryParams(String urlString) {
        final url = Uri.parse(urlString);
        return url.queryParameters["id"];
    }

    @override
    Widget build(BuildContext context) {
      return const MaterialApp(home: Scaffold());
    }
}
TypeScript
import React, { useEffect } from 'react';
import branch from 'react-native-branch';
import { NavigationUtils } from './NavigationUtils';

const navigationUtils = new NavigationUtils();

const App = () => {
  useEffect(() => {
    const unsubscribe = branch.subscribe({
      onOpenStart: ({ uri, cachedInitialEvent }) => {
         // Optional: called just before Branch resolves the link
      },
      onOpenComplete: ({ error, params, uri }) => {
         if (error) {
           console.error('Error from Branch: ' + error);
           return;
         }

            if (params?.['+clicked_branch_link']) {
              if (params['$web_only']) {
                // Route to web
                return;
              }

               const canonicalUrl = params['$canonical_url'] as string | undefined;
               const deeplinkPath = params['$deeplink_path'] as string | undefined;

              if (canonicalUrl) {
                deepLinkUsingCanonicalURL(canonicalUrl);
              } else if (deeplinkPath) {
                deepLinkUsingDeepLinkPath(deeplinkPath);
              }
            } else {
              // Handle non-branch deep linking
            }
          },
        });

      return () => unsubscribe();
    }, []);

    const deepLinkUsingCanonicalURL = (canonicalURL: string) => {
       const url = new URL(canonicalURL, 'https://fallback.com');
       switch (url.pathname) {
         case "/shop/items.html":
           navigationUtils.loadShopScreen();
           break;
         case "/shop/item-detail.html":
           navigationUtils.loadShopScreen(getIdFromQueryParams(canonicalURL) || "");
           break;
       }
    };

    const deepLinkUsingDeepLinkPath = (deepLinkPath: string) => {
      if (deepLinkPath.includes("shop")) {
         if (deepLinkPath.includes("item-detail")) {
           navigationUtils.loadShopScreen(getIdFromQueryParams(deepLinkPath) || "");
         } else {
           navigationUtils.loadShopScreen();
         }
     }
  };

  const getIdFromQueryParams = (urlString: string): string | null => {
     const safeUrl = urlString.startsWith('http')
       ? urlString
       : `https://dummy.com${urlString.startsWith('/') ? '' : '/'}${urlString}`;
     const url = new URL(safeUrl);
     return url.searchParams.get("id");
  };

  return null;
};

export default App;
Rob Gioia

Rob Gioia

Rob Gioia is a Principal Solutions Architect at Branch who has written several Branch blog posts and hosted many Branch webinars. He primarily helps new customers get up-and-running with Branch’s SDK and suite of products. In his spare time, he is a Udemy instructor with 25 published online courses.