+1 (415) 843-4662

Deep links in Ionic apps: Universal Links, App Links and a back stack that makes sense

Every app we audit eventually hits the same bug report: "the link in the email opened the website instead of the app." Deep links look trivial — register a domain, handle a URL — but the details are where teams lose days: an association file served with the wrong content type, an Android signing certificate fingerprint that only matches the debug build, a router that pushes the target page underneath the tab the user was on, or a link that lands a logged-out user on a paywall with no way back.

This tutorial wires deep links end to end in an Ionic app on Capacitor: custom scheme links for the easy cases, verified Universal Links (iOS) and App Links (Android) for links that must open the app silently, routing that produces a sane back stack, and deferred links that survive an install. Examples use Ionic Angular; the Capacitor and native parts are identical for React and Vue, and the routing section notes the differences.

The three kinds of link

KindExampleVerified?Opens app without a chooser
Custom schememyapp://orders/1042NoYes, but any app can claim the scheme
Universal Link (iOS)https://example.com/orders/1042Yes, via AASA fileYes
App Link (Android)https://example.com/orders/1042Yes, via assetlinks.jsonYes

Use HTTPS links (Universal/App Links) as your public-facing format: they work in email clients, they fall back to the website when the app is not installed, and they cannot be hijacked. Keep the custom scheme registered too — OAuth redirects, some third-party SDKs, and your own QA scripts still use it.

1. Register the custom scheme

Capacitor already listens for appUrlOpen. You only need to declare the scheme natively.

iOSios/App/App/Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.example.app</string>
    <key>CFBundleURLSchemes</key>
    <array><string>myapp</string></array>
  </dict>
</array>

Androidandroid/app/src/main/AndroidManifest.xml, inside the main activity:

<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="myapp" />
</intent-filter>

Test immediately, before writing any TypeScript:

# iOS simulator
xcrun simctl openurl booted "myapp://orders/1042"

# Android device or emulator
adb shell am start -a android.intent.action.VIEW -d "myapp://orders/1042" com.example.app

If the app comes to the foreground, the native side is correct and every later problem is in your JavaScript.

2. Verified HTTPS links: iOS

Add the Associated Domains capability in Xcode (Signing & Capabilities → Associated Domains) with the entry applinks:example.com. If you support a marketing subdomain and a short-link domain, add one entry per host — wildcards (applinks:*.example.com) work but verify every host you care about in practice.

Then serve this at https://example.com/.well-known/apple-app-site-association:

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID1234.com.example.app"],
        "components": [
          { "/": "/orders/*", "comment": "order detail" },
          { "/": "/invite/*", "comment": "invite links" },
          { "/": "/blog/*", "exclude": true, "comment": "keep marketing in the browser" }
        ]
      }
    ]
  }
}

Rules that cost people a day each:

  • No .json extension on the filename.
  • Serve it as application/json, over HTTPS, with no redirect. A 301 from example.com to www.example.com breaks verification.
  • It must be reachable without authentication, and from Apple's CDN — if your staging site is behind basic auth, Universal Links will not work there.
  • Order matters in components: the first match wins, so put exclude rules before the broad patterns they carve out.

Check it the way Apple does:

curl -sSI https://example.com/.well-known/apple-app-site-association | grep -i content-type
curl -sS  https://example.com/.well-known/apple-app-site-association | head

On device, Settings → Developer → Universal Links → Diagnostics tells you whether a given URL is associated with your app.

3. Verified HTTPS links: Android

Add an autoVerify intent filter:

<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="example.com" android:pathPrefix="/orders" />
  <data android:scheme="https" android:host="example.com" android:pathPrefix="/invite" />
</intent-filter>

And serve https://example.com/.well-known/assetlinks.json:

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.example.app",
    "sha256_cert_fingerprints": [
      "AA:BB:CC:...:99",
      "11:22:33:...:FF"
    ]
  }
}]

The fingerprint trap: with Play App Signing, the certificate that signs the build users install is Google's, not yours. List both — the upload/debug key you build locally with, and the app signing key shown in Play Console → Setup → App integrity. Get your local one with:

keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android | grep SHA256

Verification is checked at install time. To force a re-check while testing:

adb shell pm verify-app-links --re-verify com.example.app
adb shell pm get-app-links com.example.app

You want to see verified next to your domain. legacy_failure almost always means the fingerprint or the file is wrong.

4. Handling the URL in the app

One service, registered once at bootstrap. It handles both the cold-start case (getLaunchUrl) and the warm case (appUrlOpen).

import { Injectable, NgZone } from '@angular/core';
import { App, URLOpenListenerEvent } from '@capacitor/app';
import { Router } from '@angular/router';

@Injectable({ providedIn: 'root' })
export class DeepLinkService {
  private pending: string | null = null;
  private ready = false;

  constructor(private router: Router, private zone: NgZone) {}

  async init() {
    const launch = await App.getLaunchUrl();          // cold start
    if (launch?.url) this.handle(launch.url);

    App.addListener('appUrlOpen', (e: URLOpenListenerEvent) => {
      this.zone.run(() => this.handle(e.url));         // back into Angular
    });
  }

  /** Call after auth state and any required bootstrap data are known. */
  setReady() {
    this.ready = true;
    const url = this.pending;
    this.pending = null;
    if (url) this.handle(url);
  }

  private handle(rawUrl: string) {
    if (!this.ready) { this.pending = rawUrl; return; }

    const route = this.toRoute(rawUrl);
    if (!route) return;                                // unknown link: ignore

    this.router.navigateByUrl(route);
  }

  private toRoute(rawUrl: string): string | null {
    let url: URL;
    try { url = new URL(rawUrl); } catch { return null; }

    // Accept only hosts we own, plus our own scheme.
    const allowed = ['example.com', 'www.example.com', 'link.example.com'];
    if (url.protocol.startsWith('http') && !allowed.includes(url.hostname)) return null;

    const path = url.pathname.replace(/^\/+/, '');
    const [section, id] = path.split('/');

    switch (section) {
      case 'orders':  return id ? `/tabs/orders/${encodeURIComponent(id)}` : '/tabs/orders';
      case 'invite':  return id ? `/invite/${encodeURIComponent(id)}` : null;
      case 'settings': return '/tabs/settings';
      default: return null;
    }
  }
}

Wire it up in app.component.ts (or main.tsx for React, where you would use the same class without NgZone and call history.push):

export class AppComponent {
  constructor(deepLinks: DeepLinkService, auth: AuthService) {
    deepLinks.init();
    auth.restoreSession().then(() => deepLinks.setReady());
  }
}

Three things this small amount of code buys you:

  1. No race. Links that arrive before the session is restored are queued, not dropped, and not sent to a guard that bounces the user to login.
  2. An allow-list. Never navigateByUrl(url.pathname) straight from an external URL — that lets anyone with a myapp:// link push arbitrary internal routes, including screens you assume are only reachable after a purchase or an admin check.
  3. A single mapping table. Web routes and app routes drift apart over time; toRoute() is where you absorb that, and it is trivially unit testable without a device.

5. Back stacks that make sense

The most common complaint after deep links ship is "the back button exits the app." A deep link that lands on a detail page with an empty history gives the user nowhere to go.

For Ionic Angular, navigate with an explicit "root" when the link starts a new flow, and let the router animate forward when it does not:

import { NavController } from '@ionic/angular';

// inside handle(), instead of router.navigateByUrl:
if (this.router.url === '/' || this.router.url.startsWith('/splash')) {
  this.nav.navigateRoot(route);        // cold start: this becomes the root
} else {
  this.nav.navigateForward(route);     // app already open: push, back works
}

For a detail page reached cold, give it a defaultHref so the back button has a destination:

<ion-buttons slot="start">
  <ion-back-button defaultHref="/tabs/orders"></ion-back-button>
</ion-buttons>

In Ionic React the equivalent is <IonBackButton defaultHref="..."> plus router.push(route, 'forward', 'replace') for the cold-start case.

6. Deferred deep links (the install case)

If the app is not installed, the HTTPS link opens your website. If the user then installs the app, the original context is gone — iOS and Android do not hand it to the fresh install. You have three honest options:

  • Do nothing. Land them on the home screen. Fine for content links.
  • Pass it through the store listing on Android. Play supports a referrer parameter on install URLs; read it with the Install Referrer API on first launch.
  • Ask the user. A "have a code?" field on the first screen, prefilled by a copy-to-clipboard hint on the web page, is unglamorous but converts and adds no third-party SDK.

Whichever you choose, make the web fallback page useful: show the content, show a clear "Open in app" button that fires the HTTPS link (which will be captured by the app if installed), and keep the invite/order context visible so the user can re-enter it manually.

A robust web-side smart banner is just this:

<a href="https://example.com/orders/1042" data-open-in-app>Open in the app</a>

Do not attempt scheme-then-timeout hacks. Modern browsers block them, and on iOS they produce an "Cannot open page" dialog that looks like your app is broken.

7. A test matrix that catches the real bugs

Run these on both platforms before you call it done. Every row here has burned a client project we inherited:

ScenarioExpected
App killed, tap link in MailApp launches straight to target
App backgrounded, tap link in NotesApp resumes on target, back returns to previous screen
Tap link in the in-app browser of another appOpens your app, not a nested webview
Logged-out user taps a protected linkLogin, then target — not home
Unknown path (/promo/xyz)Website opens, app does not crash
myapp://tabs/admin from a strangerIgnored (not in allow-list)
Long-press the link in SafariMenu shows "Open in App name"
Fresh install from Play internal testing trackStill verified (signing key!)

Automate the first two in your Maestro flows so a future Capacitor upgrade cannot silently break them:

appId: com.example.app
---
- launchApp:
    clearState: true
- openLink: https://example.com/orders/1042
- assertVisible: "Order #1042"

Common failure modes, quickly diagnosed

  • Link opens Safari instead of the app (iOS). AASA is redirected, wrong content type, or behind auth. Delete and reinstall the app — iOS caches the association aggressively.
  • Android shows a chooser dialog. autoVerify missing, or verification failed. Run adb shell pm get-app-links.
  • Works in debug, fails from the store. Play App Signing fingerprint not in assetlinks.json.
  • App opens but stays on the splash screen. Your handler ran before the router was ready — that is what the pending queue in step 4 is for.
  • Nothing happens on warm open in Angular. The listener fired outside the zone; wrap it in NgZone.run().
  • Links work, then stop after adding a service worker. The worker is intercepting .well-known requests on the web side; exclude that path.

Where this fits

Deep links are the plumbing behind push notifications (a notification tap is a deep link), email campaigns, referral programs and QR codes in the physical world. Getting them verified once means every one of those channels lands users on the right screen for the life of the app.

If you want a second pair of eyes on an association file that refuses to verify, or you are retro-fitting deep links into an app with a complicated tab and modal structure, our senior Ionic consultants do this regularly — get in touch and we will look at your setup.