Mobile App Deep Linking

Implement deep linking that navigates users to specific content within your mobile app. Covers URI schemes, Universal Links, App Links, deferred deep links, attribution, and the patterns that create seamless navigation between web and native app.

Mobile apps are most effective when users can jump directly to a specific screen or state, bypassing home screens and navigation trees. Deep linking enables this: a URL that opens an app and navigates to a precise location, such as /product/123?color=blue&size=XL. This is essential when sharing links via email, SMS, web, or QR codes, and critical during onboarding, campaign tracking, and user retention.

Deep links are URLs that target specific content or actions within a mobile app. They follow a standard format: scheme://host/path?query. The scheme is a custom protocol identifier like myapp://, shop://, or com.example:123. When a user taps a deep link, the system checks if the app is installed. If it is, the app launches and receives the full URL. If not, the system can open a fallback web page or direct the user to an app store.

A deep link might be shop://products/456?category=electronics&ref=ads-2024. The app parses the path, query parameters, and scheme to determine the target screen and initial state.

Defining App Schemes

To register a scheme, the app declares it in the native platform’s manifest. On iOS, this is in Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.example.shop</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>shop</string>
      <string>shop-dev</string>
    </array>
  </dict>
</array>

On Android, the declaration is in AndroidManifest.xml:

<activity
  android:name=".MainActivity"
  android:exported="true">
  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="shop" />
    <data android:scheme="shop-dev" />
  </intent-filter>
</activity>

The android.intent.category.BROWSABLE flag enables the app to be opened from a browser, not just a direct URL tap.

The app must listen for incoming deep links during launch and foregrounding. On iOS, AppDelegate.swift responds to application(_:didFinishLaunchingWithOptions:):

func application(_ app: UIApplication, 
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
  if let url = launchOptions?[.launchURL] as? URL {
    handleDeepLink(url)
  }
  return true
}

func application(_ app: UIApplication, 
                 continue userActivity: NSUserActivity, 
                 restorationHandler: @escaping ([Any]) -> Void) -> Bool {
  if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
    handleDeepLink(userActivity.webpageURL!)
  }
  return true
}

On Android, MainActivity.java overrides onCreate and onNewIntent:

@Override
protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  handleIntent(getIntent());
}

@Override
protected void onNewIntent(Intent intent) {
  super.onNewIntent(intent);
  setIntent(intent);
  handleIntent(intent);
}

private void handleIntent(Intent intent) {
  if (Intent.ACTION_VIEW.equals(intent.getAction())) {
    Uri data = intent.getData();
    if (data != null) {
      String path = data.getPath();
      Map<String, String> params = parseQuery(data.getQuery());
      navigateTo(path, params);
    }
  }
}

Once the app receives a URL, it must route the link to the correct screen. This requires a routing system that maps URL paths and query parameters to screen components.

The routing logic should be declarative and composable. A minimal router might use a switch statement or a map of path patterns to screen builders:

const routeMap = {
  '/products/:id': (params) => {
    const productId = parseInt(params.id, 10);
    return { type: 'ProductDetail', productId };
  },
  '/products': (params) => {
    return { type: 'ProductList', filters: params };
  },
  '/cart': () => ({ type: 'Cart' }),
  '/checkout': (params) => ({ type: 'Checkout', step: params.step || 'shipping' })
};

function parsePath(path) {
  const segments = path.split('/').filter(Boolean);
  const pattern = segments.map(s => s.startsWith(':') ? `:${s.slice(1)}` : s).join('/');
  const params = {};
  for (const segment of segments) {
    if (segment.startsWith(':')) {
      const key = segment.slice(1);
      const value = segments[segments.indexOf(segment) + 1];
      params[key] = value;
    }
  }
  return { pattern, params };
}

function routeFromUrl(url) {
  const { pathname, search } = new URL(url);
  const { pattern, params } = parsePath(pathname);
  const route = routeMap[pattern];
  if (!route) return { type: 'NotFound' };
  return route({ ...params, ...parseQuery(search) });
}

This router handles /products/123?category=electronics&sort=price by matching products/:id and extracting id: 123, category: electronics, sort: price.

Query Parameter Parsing

Query parameters are often misinterpreted. The standard is to parse the query string using &-delimited key-value pairs, with values URL-encoded. The app must decode the query string and apply types:

function parseQuery(queryString) {
  const params = {};
  const pairs = queryString.split('&');
  for (const pair of pairs) {
    const [key, value] = pair.split('=');
    params[decodeURIComponent(key)] = decodeURIComponent(value || '');
  }
  return params;
}

A common mistake is treating all values as strings. The app should convert booleans, numbers, and dates:

function parseTypedParams(params) {
  const result = {};
  for (const [key, value] of Object.entries(params)) {
    if (value === 'true') result[key] = true;
    else if (value === 'false') result[key] = false;
    else if (/^\d+(\.\d+)?$/.test(value)) result[key] = parseFloat(value);
    else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) result[key] = new Date(value);
    else result[key] = value;
  }
  return result;
}

Handling Fallbacks and Errors

When a deep link fails to resolve, the app should provide fallback behavior. The most common failure is a malformed or unsupported URL:

The app should log errors and optionally show a toast or a modal.

function navigateTo(route) {
  switch (route.type) {
    case 'ProductDetail':
      const { productId, color, size } = route;
      if (!productId) {
        console.error('Missing productId in ProductDetail route');
        showFallbackModal('Product not found');
        return;
      }
      showProductScreen(productId, { color, size });
      break;
    case 'NotFound':
      showFallbackModal('Page not found');
      break;
    default:
      console.warn('Unknown route type:', route.type);
      showFallbackModal('Unknown page');
  }
}

Deferred Deep Linking

When the app is not installed, the deep link must be handled after installation. Deferred deep linking uses a bridge between the web and the app. The process involves:

  1. User taps a deep link: shop://products/123.
  2. App not installed → redirect to https://shop.example.com/products/123.
  3. User downloads and installs the app.
  4. On first launch, the app reads a deepLink parameter passed via the install flow.

On iOS, the app receives the deep link via application(_:didFinishLaunchingWithOptions:) with launchOptions[.launchURL] set.

On Android, the intent includes android.intent.extra.REFERRER and android.intent.extra.DEVICE_ID.

The app can store the deep link in SharedPreferences or UserDefaults during installation, then read it on next launch.

Testing deep links requires simulating both installed and not-installed scenarios.

Testing with curl and adb

Use curl to simulate a web click on a deep link:

curl -v -H "User-Agent: Mobile/1.0" \
     https://shop.example.com/products/123?ref=ads-2024

Use adb (Android Debug Bridge) to install and test a deep link:


adb install app-debug.apk

# Launch the app with a deep link
adb shell am start -a android.intent.action.VIEW \
                   -d "shop://products/123?color=blue" \
                   -n com.example.shop/.MainActivity

# Check logs
adb logcat | grep -i "deep link"

On iOS, use xcrun simctl:

# Launch simulator
xcrun simctl boot "iPhone 15"

# Open deep link in simulator
xcrun simctl openurl booted "shop://products/123?color=blue"

Debugging Common Failures

Best Practices

Failure Modes

Deep linking is not just a feature—it is a contract between systems. When a link is shared, the receiving system must understand it, and the app must honor it. The result is a seamless experience where users feel like they are in the app from the moment they tap a link.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.