Handling Deep Links
Learn how to register custom URL schemes, listen for deep link events, and process incoming URLs in your Tauri v2 application with the Deep Link Plugin
A deep link is a URL that causes the operating system to open a specific application instead of a browser. When a user clicks a link like myapp://settings/profile or a verified website link tied to your app, the system can launch your Tauri window and hand you the full URL. This is the foundation for OAuth redirects, email verification flows, invitation links, and any cross-platform feature that needs to bring the user back into your desktop or mobile app.
The tauri-plugin-deep-link plugin makes your Tauri v2 application the target of those URLs. It handles registering custom schemes, receiving URLs while the app is running, and giving you a consistent API across Windows, Linux, macOS, iOS, and Android.
Prerequisites:
You should already have the deep-link plugin installed and initialized in your Tauri project. If you haven't, follow the setup steps below, or start from the Deep Link Plugin introduction, before moving on to handling links.
Setting Up the Plugin
If you haven't added the plugin yet, the following steps get the Rust and JavaScript sides wired up. Choose the manual path if you need explicit control; otherwise the tauri add command handles all of this automatically.
# Quick automatic setup (recommended)
npm run tauri add deep-link
If you prefer to install manually, proceed through the steps below.
Step 1: Add the Rust plugin dependency
Add the crate to src-tauri/Cargo.toml:
[dependencies]
tauri-plugin-deep-link = "2"
Step 2: Register the plugin in your Rust code
Open src-tauri/src/lib.rs and attach the plugin to the Tauri builder:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_deep_link::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
This is the minimal hook. Later sections add listeners inside .setup().
Step 3: Install the JavaScript guest bindings
Use your package manager to add the frontend library:
npm install @tauri-apps/plugin-deep-link
All set:
Once these steps are complete, the plugin is active in your app. You must tell the operating system which URLs to forward to you.
Configuring Deep Links
You declare which URL schemes and domains your app should handle inside tauri.conf.json under the plugins.deep-link key. The structure differs slightly between mobile and desktop because the platforms have different linking mechanisms.
Mobile Configuration (iOS and Android)
On mobile you have two choices:
- Custom URI schemes – like
myapp://– that work without any server verification. Just define aschemeand setappLinktofalse(or omit it). - Verified links (App Links / Universal Links) – regular
https://URLs tied to a domain you own. These require hosting an association file on your server to prove you control the domain. SetappLink: trueand provide thehostand optionalpathPrefix.
{
"plugins": {
"deep-link": {
"mobile": [
{
"scheme": ["myapp"],
"appLink": false
},
{
"scheme": ["https"],
"host": "example.com",
"pathPrefix": ["/open"],
"appLink": true
}
]
}
}
}
The first entry registers myapp://*. The second entry registers https://example.com/open/* as a verified app link. When a user taps that HTTPS link and your app is installed, the system opens your app directly instead of the browser.
Verified links require server files:
For App Links (Android) you need /.well-known/assetlinks.json and for Universal Links (iOS) you need /.well-known/apple-app-site-association, both served over HTTPS. Without these, the verified links will fall back to opening in the browser. Refer to the Android and iOS documentation for the exact file format and certificate fingerprints.
Desktop Configuration
On Windows, Linux, and macOS you can only use custom schemes (no https verified links). Declare them under the desktop key:
{
"plugins": {
"deep-link": {
"desktop": {
"schemes": ["my-tauri-app", "anotherscheme"]
}
}
}
}
This tells the installer to associate my-tauri-app:// and anotherscheme:// with your application. On macOS the scheme is registered automatically; on Windows and Linux the URLs are delivered as command-line arguments to a new process unless you combine the deep-link plugin with the single-instance plugin (covered later).
Listening for Deep Links
After configuration, your app needs code that reacts when a URL arrives. The plugin provides two mechanisms:
getCurrent()– returns the URL(s) that launched the app if it was started by a deep link.onOpenUrl()– fires a callback every time a new deep link is received while the app is already running.
Both are available in JavaScript and Rust. Use getCurrent() during startup to catch the link that opened the app. Use onOpenUrl() to handle subsequent links.
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
export function useDeepLink() {
const navigate = useNavigate();
const handlerRef = useRef(false);
useEffect(() => {
// Prevent double registration in React Strict Mode
if (handlerRef.current) return;
handlerRef.current = true;
// Handle the URL that launched the app
getCurrent().then((urls) => {
if (urls && urls.length > 0) {
processUrl(urls[0]);
}
});
// Handle URLs that arrive while the app is already open
const unlisten = onOpenUrl((urls) => {
if (urls.length > 0) {
processUrl(urls[0]);
}
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
function processUrl(url: string) {
try {
const parsed = new URL(url);
// Example: navigate to path stored after the scheme
const path = parsed.host + parsed.pathname || "/";
navigate(path);
} catch {
// Custom schemes like myapp://settings don't parse as URL
// Split manually instead
const path = url.includes("://") ? url.split("://")[1] || "/" : url;
navigate(path);
}
}
}
This hook uses useRef to avoid a well‑known pitfall: onOpenUrl internally calls getCurrent() when the listener is first attached, so React's double‑mount in Strict Mode can cause the same URL to be processed twice. The guard prevents that.
Double firing on mount:
In plugin versions prior to 2.4.x, onOpenUrl re‑delivers the initial URL every time a new listener is registered. If your component remounts (e.g., during navigation), the same deep link fires again, causing redirect loops. The useRef guard shown here protects against that. If you are on a newer version where this has been patched, the guard is still safe and harmless.
Desktop and the Single-Instance Plugin
On Windows and Linux, a deep link launches a new process by default, passing the URL as a command-line argument. If you want a single running instance to receive all URLs (matching the mobile behavior), you must combine the deep-link plugin with the tauri-plugin-single-instance plugin and enable its deep-link feature.
[target."cfg(any(target_os = \"linux\", windows))".dependencies]
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
Then register it before the deep-link plugin:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default();
#[cfg(desktop)]
{
builder = builder.plugin(
tauri_plugin_single_instance::init(|_app, argv, _cwd| {
println!("New instance opened with args: {:?}", argv);
}),
);
}
builder
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
// ... listeners as shown above
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Forgetting the single-instance plugin on desktop:
Without the single-instance integration, each deep link spawns a brand‑new app window on Windows and Linux. The onOpenUrl listener in the original process never sees the URL. If you skip this step, your desktop app won't behave like a single‑window application.
Processing Deep Link Data
A raw URL string like my-tauri-app://profile/edit?tab=security carries meaning in its structure. Your job is to pull out the parts that matter — the path, query parameters, maybe a token — and turn them into navigation or an action inside your app.
The JavaScript URL constructor works for standard https:// links, but it throws an error on custom schemes. A robust processor handles both cases.
Extracting the Route from a Custom Scheme
For a custom scheme, the portion after :// is the actionable part. A simple split plus fallback covers most needs:
function extractPath(deepLinkUrl: string): string {
// Handle https://example.com/open/dashboard
if (deepLinkUrl.startsWith("http")) {
const url = new URL(deepLinkUrl);
return url.pathname;
}
// Handle myapp://settings/profile
const segments = deepLinkUrl.split("://");
return segments.length > 1 ? segments[1] : "/";
}
Passing Tokens from an OAuth Callback
OAuth flows often redirect with a URL like myapp://callback?code=abc123&state=xyz. You need to extract the authorization code while preserving the state parameter for security checks.
function parseOAuthCallback(deepLinkUrl: string): Record<string, string> {
// Standard URLSearchParams works after we strip the scheme
const queryString = deepLinkUrl.includes("?")
? deepLinkUrl.substring(deepLinkUrl.indexOf("?"))
: "";
return Object.fromEntries(new URLSearchParams(queryString).entries());
}
Validate the state parameter:
If your OAuth flow includes a state parameter to prevent CSRF, always verify it matches the value you stored before the redirect. Never trust the callback URL blindly — an attacker could craft a link with a stolen authorization code.
Integrating with React Router
The useDeepLink hook shown earlier calls navigate(path) with the extracted route. For a flat navigation structure, that's enough. If you need to forward query parameters as route state, you can expand the processor:
// Inside the hook's processUrl function
if (deepLinkUrl.includes("?")) {
const path = deepLinkUrl.split("?")[0].split("://")[1] || "/";
const params = new URLSearchParams(deepLinkUrl.split("?")[1]);
navigate(path, { state: Object.fromEntries(params.entries()) });
} else {
const path = deepLinkUrl.split("://")[1] || "/";
navigate(path);
}
This approach keeps your React components agnostic of the deep-link plumbing — they just receive their usual route props.
Dynamic Protocol Registration (Desktop Only)
The tauri.conf.json configuration defines schemes at build time. On Windows and Linux you can also register and unregister schemes while the app is running, which is useful for opt‑in features or custom user‑supplied schemes.
Platform support for dynamic registration:
Dynamic registration is only available on Windows and Linux. On macOS, Android, and iOS, schemes must be declared statically in configuration files that ship with the application bundle.
JavaScript API
import { register, unregister, isRegistered } from "@tauri-apps/plugin-deep-link";
// Check if your app already owns the scheme
const alreadyDefault = await isRegistered("myapp");
if (!alreadyDefault) {
// Register the scheme; the OS will now open myapp:// links with this app
await register("myapp");
console.log("Registered myapp://");
}
// Later, if the user disables the feature
await unregister("myapp");
isRegistered and register / unregister accept the protocol name without the :// suffix.
Rust API
use tauri_plugin_deep_link::DeepLinkExt;
// Inside .setup() with the AppHandle
let handle = app.handle();
handle.deep_link().register("my-tauri-app").unwrap();
// Later: handle.deep_link().unregister("my-tauri-app").unwrap();
When you register a scheme dynamically, the plugin writes the association to the system registry (Windows) or desktop file (Linux). Existing instances of the app do not automatically receive URLs that triggered a new process — you still need the single‑instance plugin to forward them to the running instance.
Platform-Specific Considerations
Android App Links (Verified HTTPS)
For appLink: true configurations, you must host an assetlinks.json file at https://yourdomain/.well-known/assetlinks.json. The file contains your app's package name and the SHA‑256 fingerprint of your signing certificate. Without it, Android falls back to showing a disambiguation dialog.
iOS Universal Links
iOS requires the apple-app-site-association file at https://yourdomain/.well-known/apple-app-site-association. The file lists your Team ID and bundle identifier. Apple's CDN caches this file, so changes may take time to propagate. Test with:
curl -v https://app-site-association.cdn-apple.com/a/v1/yourdomain.com
macOS Custom Schemes
On macOS, the scheme is registered via the app bundle's Info.plist. The deep-link plugin generates the necessary CFBundleURLTypes entries automatically from your tauri.conf.json desktop schemes. No extra plist editing is required.
Windows / Linux
Always pair the deep-link plugin with the single‑instance plugin unless you intentionally want multiple app windows. The deep-link feature of the single‑instance plugin ensures the running instance receives the URL event from new process spawns.
Summary
Handling deep links comes down to three steps: declare the schemes and domains you own in tauri.conf.json, listen for incoming URLs through getCurrent and onOpenUrl, and then extract the meaningful parts to control your app's navigation or logic.
The most common mistake is not guarding against duplicate URL processing when listeners remount. Using a useRef flag in React, or checking for prior registration in vanilla JavaScript, eliminates the loop. On desktop, skipping the single‑instance plugin is the next most frequent oversight — your URLs will land in a new window instead of your current session.
When building an OAuth flow, the deep-link plugin is exactly the mechanism that catches the redirect and hands you the authorization code. Combined with the HTTP plugin for token exchange, you have the full authentication pipeline without leaving the desktop environment. For tighter platform integration, check the plugin's Rust API to validate tokens or store credentials before they ever reach the frontend.