How Plugins Work
Understand the internal architecture of a Tauri plugin – the Rust crate, JavaScript bindings, command invocation, event flow, and the lifecycle that powers every plugin.
A Tauri plugin is not a single piece of code. It is a Rust crate on the backend, optionally paired with an NPM package on the frontend, that together expose new native capabilities to your web view. To use plugins effectively – and to avoid confusion when something goes wrong – you need to see what happens under the hood when a plugin is registered, when a command is called, and when events travel between the Rust process and your React code. That path is the same IPC model described in Connecting Backend to Frontend.
The Two Halves of a Plugin
Every plugin that provides an API to your frontend consists of two separate, cooperating parts.
- The Rust crate – lives in
src-tauri/(either as a dependency inCargo.tomlor as a local crate). This is where the real native logic happens. It registers commands, hooks into the Tauri lifecycle, manages state, and defines how the plugin interacts with the operating system or other Rust libraries. - The optional NPM package – installed in your React/Vite project via
npm install @tauri-apps/plugin-<name>. This package contains JavaScript (or TypeScript) functions that call into the Rust commands using Tauri’sinvokeAPI. It wraps raw command strings into typed, documented functions so you don’t have to writeinvoke('plugin:command', { ... })manually.
A plugin does not have to ship a JavaScript package. If it only provides Rust-side functionality accessible through other plugins or through pure Rust integration, the NPM package can be omitted. The official Tauri plugins always ship both because they are meant to be used from the frontend.
Two packages, one plugin:
The Rust crate is named tauri-plugin-<name>. The JavaScript package is named @tauri-apps/plugin-<name> (or tauri-plugin-<name>-api if not scoped). They are versioned separately but are designed to work together. Always match compatible versions when you install manually.
The Rust Side – More Than Just Commands
The Rust crate is the brain of the plugin. It exposes a function – conventionally named init – that returns a TauriPlugin struct. That struct is then handed to Tauri’s Builder::plugin() method in your application’s lib.rs. Everything the plugin can do is configured inside that builder.
Lifecycle Hooks
A plugin can hook into specific moments of the Tauri application lifecycle. These hooks let you run code when the app starts, when a window navigates, when the event loop processes events, and when the plugin is dropped.
setup– Called once when the plugin is initialized. This is where you register managed state, spawn background tasks, and set up mobile plugin bridges.on_navigation– Called when a webview attempts to navigate to a URL. You can inspect the URL and returnfalseto cancel the navigation.on_webview_ready– Called after a new webview window is created. Useful for injecting initialization scripts or attaching event listeners to that window.on_event– Receives every TauriRunEvent. You can react to exit requests, window close events, menu events, and more.on_drop– Called when the plugin is being destroyed. Use it for cleanup tasks.
Here is a Rust snippet that uses setup to initialize shared state and spawn a background tick emitter.
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
use std::{
collections::HashMap,
sync::Mutex,
time::Duration,
};
struct AppState {
counters: Mutex<HashMap<String, u32>>,
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("my-plugin")
.setup(|app, _api| {
// Register state that commands can access
app.manage(AppState {
counters: Mutex::new(HashMap::new()),
});
// Spawn a background tick every second
let handle = app.handle().clone();
std::thread::spawn(move || loop {
handle.emit("tick", ()).ok();
std::thread::sleep(Duration::from_secs(1));
});
Ok(())
})
.build()
}
The setup closure receives an App handle and an api object that gives access to the plugin’s parsed configuration. The managed state (AppState here) will be available to any command registered by this plugin through Tauri’s state system.
Commands – The Callable API
Commands are Rust functions annotated with #[tauri::command] that can be called from the frontend. A plugin registers its commands on the builder. Each command becomes accessible under the plugin’s namespace.
A plugin command typically looks like this inside commands.rs:
use tauri::State;
use std::collections::HashMap;
use std::sync::Mutex;
#[tauri::command]
pub fn increment_counter(
state: State<'_, super::AppState>,
key: String,
) -> Result<u32, String> {
let mut counters = state.counters.lock().map_err(|e| e.to_string())?;
let entry = counters.entry(key).or_insert(0);
*entry += 1;
Ok(*entry)
}
Notice how the command uses State to retrieve the AppState struct that was managed during setup. The frontend will call this as invoke('plugin:my-plugin|increment_counter', { key: 'main' }).
The builder connects the command:
Builder::new("my-plugin")
.setup(|app, _api| { /* state setup */ Ok(()) })
.invoke_handler(tauri::generate_handler![crate::commands::increment_counter])
.build()
Name collisions are silent failures:
If two plugins register a command with the same name, Tauri does not throw an error at build time. The last registration wins. Always namespace your commands with the plugin name prefix to avoid clashes.
Extension Traits – Accessing Plugin APIs From Other Rust Code
A plugin often exposes its functionality to other Rust code in your application through an extension trait. The plugin defines a struct (e.g., MyPlugin) and a trait (MyPluginExt) that adds a method to AppHandle and App returning that struct. This is how built-in plugins like global-shortcut or opener work: you call app.opener().open_path(...).
Here is a minimal example:
use tauri::{AppHandle, Runtime};
pub struct MyPluginApi {
// could hold a channel or internal handle
}
pub trait MyPluginExt<R: Runtime> {
fn my_plugin(&self) -> &MyPluginApi;
}
impl<R: Runtime> MyPluginExt<R> for AppHandle<R> {
fn my_plugin(&self) -> &MyPluginApi {
self.state::<MyPluginApi>().inner()
}
}
During setup, the plugin manages an instance of MyPluginApi. Any Rust code with an AppHandle can then call app.my_plugin() and access its methods directly.
The JavaScript Side – The Frontend’s Window Into Rust
The NPM package that comes with a plugin provides JavaScript functions that mirror the Rust commands. These functions use invoke from @tauri-apps/api/core to call the backend.
A typical plugin package exports something like this:
import { invoke } from '@tauri-apps/api/core';
export async function incrementCounter(key: string): Promise<number> {
return invoke('plugin:my-plugin|increment_counter', { key });
}
The command string follows the format plugin:<plugin-name>|<command-name>. Tauri uses the plugin name to route the call to the correct plugin’s command handler.
Listening to Events
If the Rust plugin emits events (like the tick event earlier), the JavaScript side can listen to them using the listen function from @tauri-apps/api/event. Sending Events from Rust covers the same emit/listen pair outside plugins.
import { listen } from '@tauri-apps/api/event';
const unlisten = await listen('tick', () => {
console.log('Tick received');
});
// Later: unlisten();
Events are broadcast to all windows. If a plugin needs to send data only to a specific window, it uses window.emit() in Rust instead of app.emit().
Events are not authenticated by default:
Any frontend code can listen to any event emitted by the backend, unless you restrict the event’s transmission to a specific window label. Be careful not to emit sensitive information on events that all windows can hear.
How a Command Travels From Your React Code to Rust
Understanding the exact path a command takes helps you debug issues when calls silently fail or return unexpected errors.
- Frontend calls an API function – e.g.,
incrementCounter('main'). - The function calls
invoke('plugin:my-plugin|increment_counter', { key: 'main' }). - Tauri’s IPC bridge serializes the arguments to JSON and sends them over the webview’s message channel to the Rust process.
- The Rust runtime receives the message, looks at the command string, and splits it into the plugin name (
my-plugin) and command name (increment_counter). - It dispatches the request to the plugin’s registered command handler.
- The handler runs, retrieves any
Stateit needs, performs the operation, and returns aResult. - Tauri serializes the
Okvalue (or theErrmessage) back to JSON and sends it through the IPC bridge to the frontend. - The
invokepromise resolves with the returned data, or rejects if the command returned an error.
If the command is not found, the frontend receives an error like command not found. If the plugin was not registered with .plugin() in lib.rs, the command will never be reachable.
Correct wiring confirmation:
When you see your React component call incrementCounter and receive a number back, the entire chain is working: plugin registration, state management, command dispatch, and IPC serialization.
Plugin Initialization – From Registration to Ready
When you call .plugin(my_plugin::init()) inside your Tauri builder, Tauri does the following:
- Stores the plugin’s configuration (if any) as parsed from
tauri.conf.json. - Calls the plugin’s
setuphook with theApphandle and the configuration API. - The plugin typically uses this moment to manage state and register mobile plugin instances.
- The plugin’s commands are added to the global command registry, namespaced under the plugin name.
- The plugin’s lifecycle callbacks (
on_navigation,on_webview_ready,on_event,on_drop) are registered and will be invoked at the appropriate times.
Once setup completes without error, the plugin is considered ready. Any managed state is now accessible to commands, and the frontend can start calling plugin APIs.
A Complete, Working Example
To make the internal mechanics concrete, here is a minimal plugin that stores a counter in Rust state, exposes a command to increment it, and emits an event when the count changes. The React frontend displays the count and updates it on a button click.
Rust Plugin Crate
use tauri::{
plugin::{Builder, TauriPlugin},
Manager, Runtime,
};
use std::{
collections::HashMap,
sync::Mutex,
};
struct CounterState {
values: Mutex<HashMap<String, u32>>,
}
#[tauri::command]
fn increment_counter(
state: tauri::State<'_, CounterState>,
app: tauri::AppHandle,
key: String,
) -> Result<u32, String> {
let mut values = state.values.lock().map_err(|e| e.to_string())?;
let count = values.entry(key.clone()).or_insert(0);
*count += 1;
let new_val = *count;
// Emit an event so the frontend can react
app.emit("counter-updated", (key, new_val))
.map_err(|e| e.to_string())?;
Ok(new_val)
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("counter")
.setup(|app, _api| {
app.manage(CounterState {
values: Mutex::new(HashMap::new()),
});
Ok(())
})
.invoke_handler(tauri::generate_handler![increment_counter])
.build()
}
Register the Plugin in Your App
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(counter_plugin::init()) // our custom plugin
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
JavaScript Binding (NPM package style)
This would typically live inside the plugin’s guest-js directory, but for clarity here is the code you would use in your React project if you were writing the binding manually.
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
export async function incrementCounter(key: string): Promise<number> {
return invoke('plugin:counter|increment_counter', { key });
}
export function onCounterUpdated(
callback: (key: string, value: number) => void
): Promise<() => void> {
return listen<[string, number]>('counter-updated', (event) => {
const [key, value] = event.payload;
callback(key, value);
});
}
React Component That Uses the Plugin
import { useState, useEffect } from 'react';
import { incrementCounter, onCounterUpdated } from './plugins/counter';
function Counter() {
const [count, setCount] = useState(0);
const COUNTER_KEY = 'main';
useEffect(() => {
const setup = async () => {
const unlisten = await onCounterUpdated((key, value) => {
if (key === COUNTER_KEY) {
setCount(value);
}
});
return unlisten;
};
const unlistenPromise = setup();
return () => {
unlistenPromise.then((unlisten) => unlisten());
};
}, []);
const handleClick = async () => {
try {
const newVal = await incrementCounter(COUNTER_KEY);
setCount(newVal);
} catch (err) {
console.error('Failed to increment counter:', err);
}
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Increment</button>
</div>
);
}
export default Counter;
The component listens for the counter-updated event to synchronize with the backend state, and also sets the count optimistically from the command return value. If another window increments the counter, the event will update this component as well.
Always handle errors from invoke:
If the frontend calls a command that the Rust backend cannot find (plugin not registered, permission denied), the promise rejects. Unhandled rejections in event handlers or UI callbacks will appear as silent failures unless you catch them.
Where the Frontend and Backend Meet
The plugin architecture creates a clean contract between your React code and Rust:
- Commands are the primary request–response mechanism. The frontend asks for a specific operation and gets an answer.
- Events push information from Rust to the frontend without a prior request, useful for state changes triggered by the backend (timers, file watchers, incoming network data).
- Managed state in Rust is shared across commands, but the frontend never accesses it directly. It can only observe it through command results and events.
This separation is what makes Tauri plugins safe: JavaScript code in the webview has no direct access to the file system, system processes, or other native resources. It must go through the plugin’s commands, which are subject to Tauri’s capability permission system.
Without the plugin, the frontend has no way to call native code at all. The plugin is the bridge, and understanding how that bridge is built – from Rust Builder to JavaScript invoke – is the key to building reliable Tauri applications.