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
| Kind | Example | Verified? | Opens app without a chooser |
|---|---|---|---|
| Custom scheme | myapp://orders/1042 | No | Yes, but any app can claim the scheme |
| Universal Link (iOS) | https://example.com/orders/1042 | Yes, via AASA file | Yes |
| App Link (Android) | https://example.com/orders/1042 | Yes, via assetlinks.json | Yes |
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.
iOS — ios/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>
Android — android/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
.jsonextension on the filename. - Serve it as
application/json, over HTTPS, with no redirect. A 301 fromexample.comtowww.example.combreaks 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 putexcluderules 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:
- 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.
- An allow-list. Never
navigateByUrl(url.pathname)straight from an external URL — that lets anyone with amyapp://link push arbitrary internal routes, including screens you assume are only reachable after a purchase or an admin check. - 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
referrerparameter 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:
| Scenario | Expected |
|---|---|
| App killed, tap link in Mail | App launches straight to target |
| App backgrounded, tap link in Notes | App resumes on target, back returns to previous screen |
| Tap link in the in-app browser of another app | Opens your app, not a nested webview |
| Logged-out user taps a protected link | Login, then target — not home |
Unknown path (/promo/xyz) | Website opens, app does not crash |
myapp://tabs/admin from a stranger | Ignored (not in allow-list) |
| Long-press the link in Safari | Menu shows "Open in App name" |
| Fresh install from Play internal testing track | Still 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.
autoVerifymissing, or verification failed. Runadb 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
pendingqueue 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-knownrequests 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.