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.

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

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.
fun deepLinkUsingCanonicalURL(canonicalURL : String) {
val url = URL(canonicalURL)
func deepLinkUsingCanonicalURL(canonicalURL: String) {
guard let url = URL(string: canonicalURL) else { return }
void deepLinkUsingCanonicalURL(String canonicalURL) {
final url = Uri.parse(canonicalURL);
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.
when(url.path) {
"/shop/items.html" -> {
navigationUtils.loadShopScreen()
}
"/shop/item-detail.html" -> {
val id = getIdFromQueryParams(canonicalURL)
navigationUtils.loadShopScreen(id)
}
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
}
switch (url.path) {
case "/shop/items.html":
navigationUtils.loadShopScreen();
case "/shop/item-detail.html":
final id = getIdFromQueryParams(canonicalURL);
navigationUtils.loadShopScreen(id);
}
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.

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.

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.
fun deepLinkUsingDeepLinkPath(deepLinkPath : String) {
if(deepLinkPath.contains("shop")) {
func deepLinkUsingDeepLinkPath(deepLinkPath: String) {
if deepLinkPath.contains("shop") {
void deepLinkUsingDeepLinkPath(String deepLinkPath) {
if (deepLinkPath.contains("shop")) {
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).
if(deepLinkPath.contains("item-detail")) {
val id = getIdFromQueryParams(deepLinkPath)
navigationUtils.loadShopScreen(id.toString())
} else {
navigationUtils.loadShopScreen()
}
if deepLinkPath.contains("item-detail") {
let id = getIdFromQueryParams(url: deepLinkPath)
navigationUtils.loadShopScreen(id: id ?? "")
} else {
navigationUtils.loadShopScreen()
}
if (deepLinkPath.contains("item-detail")) {
final id = getIdFromQueryParams(deepLinkPath);
navigationUtils.loadShopScreen(id.toString());
} else {
navigationUtils.loadShopScreen();
}
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.

If you’re wondering what the getIdFromQueryParams function looks like, here it is:
fun getIdFromQueryParams(url : String) : String {
val urlQuerySanitizer = UrlQuerySanitizer()
urlQuerySanitizer.allowUnregisteredParamaters = true
urlQuerySanitizer.parseUrl(url)
val id = urlQuerySanitizer.getValue("id")
return id
}
func getIdFromQueryParams(url: String) -> String? {
guard let urlObj = URLComponents(string: url) else { return nil }
return urlObj.queryItems?.first(where: { $0.name == "id" })?.value
}
String? getIdFromQueryParams(String urlString) {
final url = Uri.parse(urlString);
return url.queryParameters["id"];
}
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.
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
}
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
}
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
}
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.
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
}
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
}
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
}
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:
- 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.
- 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.
- 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
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()
}
}
}
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)
}
}
}
}
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());
}
}
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;