How Tauri's Plugin System Works and How to Create a Custom Plugin

Tauri's plugin system treats extensions as first-class citizens that can inject JavaScript, register IPC commands, hook into lifecycle events, and define custom URI schemes through the Plugin<R> trait and Builder<R, C> abstraction.

The plugin architecture in the tauri-apps/tauri repository enables developers to extend both the Rust backend and JavaScript frontend without modifying core framework code. By leveraging the Builder<R, C> pattern and the Plugin<R> trait defined in crates/tauri/src/plugin.rs, you can create reusable extensions that integrate deeply with the application lifecycle across desktop and mobile platforms.

Core Architecture: Plugin Trait and Builder Pattern

Tauri's plugin system revolves around two fundamental abstractions that decouple extension logic from the core runtime.

The Plugin<R> Trait

The Plugin<R> trait in crates/tauri/src/plugin.rs (lines 36-62) defines the runtime contract every plugin must fulfill. It specifies callbacks for initialization, window events, navigation handling, and IPC command registration. When you implement this trait, you gain access to hooks like initialize, window_created, on_navigation, and extend_api, allowing your code to execute at precise moments in the application lifecycle.

The Builder<R, C> Ergonomic API

Rather than implementing Plugin<R> manually, most developers use the Builder<R, C> struct (lines 64-78 in the same file). This builder accumulates configuration through methods like invoke_handler(), js_init_script(), and on_navigation(), then constructs a TauriPlugin<R, C> that automatically implements the Plugin<R> trait. The builder stores callbacks as boxed closures (e.g., Box<OnNavigation<R>>), and the TauriPlugin implementation forwards framework calls to these stored closures.

Plugin Registration Flow

When the application starts, the plugin store (PluginStore<R>) orchestrates the following sequence:

  1. Registration: PluginStore::register stores the boxed plugin instance (lines 76-89).
  2. Initialization: PluginStore::initialize_all invokes each plugin's initialize method (lines 998-1009), executing the setup closure supplied to the builder.
  3. Event Wiring: The runtime connects lifecycle callbacks (window creation, navigation, etc.) to the plugin's stored handlers.

Lifecycle Hooks and Capabilities

Tauri plugins can intercept or extend nearly every aspect of the application runtime through these specific hooks:

Hook Execution Timing Common Use Case
initialize Immediately after App creation, before any window builds Load native resources, register URI schemes
window_created After a Window<R> instance constructs Attach window-specific state or menus
webview_created After the Webview<R> initializes Inject JavaScript listeners or preload scripts
on_navigation Before any navigation occurs Block external URLs or validate deep links
on_page_load After a page finishes loading Emit initialization events to the frontend
extend_api When an IPC invoke message arrives Provide custom commands via invoke_handler
js_init_script Before any page script executes Expose global JavaScript APIs
register_uri_scheme_protocol During initialize Handle custom schemes like myapp://

How to Create a Custom Tauri Plugin

Building a plugin follows a standardized workflow that begins with CLI scaffolding and ends with configuration in tauri.conf.json.

Step 1: Scaffold the Plugin Structure

Execute the CLI command to generate a new plugin crate:

cargo tauri plugin new my_plugin

This creates a crate under src-tauri/plugins/my_plugin using the template at crates/tauri-cli/templates/plugin/src/lib.rs. The generated init function already returns a Builder::new("my_plugin"), establishing the plugin namespace.

Step 2: Add IPC Commands

Define Rust functions using the #[tauri::command] macro and register them via the builder's invoke_handler:

use tauri::{
    plugin::{Builder, TauriPlugin},
    Runtime,
};

#[tauri::command]
fn greet(name: String) -> String {
    format!("Hello, {name}!")
}

pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new("my_plugin")
        .invoke_handler(tauri::generate_handler![greet])
        .build()
}

Step 3: Inject Frontend JavaScript

Expose a global API to the frontend by providing a js_init_script:

Builder::new("my_plugin")
    .invoke_handler(tauri::generate_handler![greet])
    .js_init_script(r#"
        window.__MY_PLUGIN__ = {
            greet: (name) => invoke('my_plugin:greet', { name })
        };
    "#)
    .build()

Step 4: Hook Into Lifecycle Events

Intercept navigation or window events using builder methods:

Builder::new("my_plugin")
    .on_navigation(|_webview, url| {
        // Allow only tauri:// scheme
        url.scheme() == "tauri"
    })
    .build()

Step 5: Configure the Application

Add the plugin name to tauri.conf.json under the plugins array, then ensure your main.rs calls the plugin's init function:

tauri::Builder::default()
    .plugin(my_plugin::init())
    .run(tauri::generate_context!())
    .expect("error while running tauri application");

Complete Implementation Examples

Minimal Standalone Plugin

This example creates a functional "adder" plugin in a standalone crate:

// src/lib.rs
use tauri::{
    plugin::{Builder, TauriPlugin},
    Runtime,
};

#[tauri::command]
fn add(a: i64, b: i64) -> i64 {
    a + b
}

pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new("adder")
        .invoke_handler(tauri::generate_handler![add])
        .js_init_script(r#"
            window.__ADDER__ = {
                add: (a, b) => invoke('adder:add', { a, b })
            };
        "#)
        .build()
}

After adding "adder" to tauri.conf.json, the plugin is available to both Rust and JavaScript.

Frontend JavaScript Usage

Access the plugin from any renderer process:

// In any webview script
window.__ADDER__.add(2, 3).then(result => console.log(result)); // prints 5

Rust-Side Plugin Access

The scaffolding generates an extension trait allowing direct access to plugin state from Rust:

fn handle_event(app: &tauri::AppHandle) {
    // The generated extension trait provides the accessor method
    let adder = app.adder();
    // Call methods defined on the plugin's internal state
}

Custom URI Scheme Handler

Register a protocol handler for synchronous or asynchronous resource loading:

use tauri::http;

Builder::new("files")
    .register_uri_scheme_protocol("myapp", |_ctx, request| {
        let path = &request.uri().path()[1..];
        let data = std::fs::read(path).unwrap_or_default();
        http::Response::builder()
            .header("Content-Type", "application/octet-stream")
            .body(data)
            .unwrap()
    })
    .build()

Mobile vs Desktop Plugin Development

While the core Builder and Plugin trait work across platforms, mobile-specific functionality resides in crates/tauri/src/plugin/mobile.rs. This module handles the Android/iOS bridge through PluginApi::run_command and manages pending call maps for asynchronous native communication. Desktop plugins may include platform-specific code using conditional compilation (e.g., #[cfg(target_os = "macos")]) within the plugin crate.

For a complete reference implementation including custom scopes and event channels, examine the official sample plugin at examples/api/src-tauri/tauri-plugin-sample/src/lib.rs.

Summary

  • Core Abstractions: The Plugin<R> trait defines the contract, while Builder<R, C> provides an ergonomic API for constructing TauriPlugin instances without manual trait implementation.
  • Registration: Plugins register via PluginStore::register and initialize through PluginStore::initialize_all (lines 998-1009 in crates/tauri/src/plugin.rs).
  • Lifecycle Integration: Hooks like initialize, window_created, on_navigation, and extend_api allow deep integration with the Tauri runtime.
  • CLI Workflow: Use cargo tauri plugin new to scaffold, then configure invoke_handler, js_init_script, and optional URI scheme protocols.
  • Cross-Platform: The same plugin architecture supports desktop and mobile, with mobile-specific bridge code isolated in mobile.rs.

Frequently Asked Questions

What is the difference between a Tauri plugin and a regular Rust module?

A Tauri plugin implements the Plugin<R> trait and registers with the PluginStore, granting it access to lifecycle hooks, automatic IPC registration, and JavaScript injection capabilities. Regular Rust modules can contain commands but lack the standardized lifecycle integration and frontend-facing APIs that the plugin system provides through Builder methods like js_init_script.

How do I share state between plugin commands?

Store state within the plugin's struct or use the setup closure to initialize shared data structures before returning the builder. The scaffolding generates an extension trait (e.g., MyPluginExt) that allows other Rust code to access this state through app_handle.my_plugin(). Alternatively, use Tauri's standard state management with app.manage() inside the plugin's initialize hook.

Can I use async/await in plugin commands?

Yes. Declare commands with async fn and Tauri's runtime will handle the async execution. When using invoke_handler(tauri::generate_handler![my_async_command]), the framework automatically awaits the future. For mobile plugins, the bridge in mobile.rs specifically manages pending asynchronous calls between the Rust core and native mobile code.

How do I debug a plugin during development?

Run your Tauri application with cargo tauri dev and use standard Rust debugging tools like println!, dbg!, or attach a debugger to the Rust process. For the JavaScript side, use the browser's DevTools to inspect the injected js_init_script and verify that global objects like window.__MY_PLUGIN__ are correctly initialized. Check the examples/api/src-tauri/tauri-plugin-sample directory for debugging patterns involving custom scopes and event channels.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →