Creating Your Own Plugin
Learn how to build custom Tauri v2 plugins to share Rust logic and frontend APIs across projects, register commands, and manage permissions
Building a plugin in Tauri v2 is the primary way to extract reusable functionality into its own crate and optional NPM package. A plugin can hook into the application lifecycle, expose Rust commands callable from JavaScript, manage state, and run native mobile code—all while keeping the main application lean. This guide walks through the entire process, from deciding whether a plugin is the right choice to sharing it across multiple projects.
Why Create Plugins?
A Tauri application can already call arbitrary Rust functions from the frontend using invoke(). The Why Create Plugins? page is the decision guide for when that is no longer enough. When a piece of logic appears in more than one project or grows large enough to deserve its own testing surface, a plugin becomes the right tool. Plugins are not simply a container for commands; they gain privileged access to Tauri’s lifecycle hooks—setup, window creation, navigation, event loop events, and teardown—allowing them to react to the application as a whole, not just to individual invocations.
A plugin is also the only way to share frontend-facing JavaScript APIs alongside Rust code in a single package. If you want other Tauri applications to npm install a library that gives them both a typed frontend client and compiled native code, a plugin is the answer.
Common reasons you would create a plugin:
- A set of commands and state that you need across multiple Tauri projects.
- Functionality that must hook into the application lifecycle (e.g., starting a background task on setup, validating navigation URLs, cleaning up resources on exit).
- Native mobile implementations that need to be exposed through a uniform Rust interface.
- Reusable frontend JavaScript helpers that wrap lower-level
invoke()calls into a developer-friendly API.
If the logic is only needed in one app and does not need lifecycle hooks, a plain tauri::command inside the application crate is simpler and sufficient. A plugin is extra ceremony, and that ceremony is only justified when the code needs to move.
Plugin Structure
A Tauri plugin is at minimum a Rust crate following the naming convention tauri-plugin-{name}. The Plugin Structure page walks through every generated file. Optionally, it can include an NPM package (tauri-plugin-{name}-api or @scope/plugin-{name}) that provides JavaScript bindings, as well as Android and iOS projects. When you run npx @tauri-apps/cli plugin new <name>, the CLI scaffolds the entire structure:
tauri-plugin-[name]/
├── Cargo.toml # Rust crate metadata
├── package.json # NPM package metadata (if not --no-api)
├── src/
│ ├── lib.rs # Plugin entry point, re-exports, setup
│ ├── commands.rs # #[command] functions exposed to frontend
│ ├── desktop.rs # Desktop-specific implementation
│ ├── mobile.rs # Mobile-specific implementation
│ ├── error.rs # Default error type for results
│ └── models.rs # Shared structs used across the plugin
├── permissions/ # Auto-generated and custom permission files
├── guest-js/ # Source code for the JavaScript bindings
├── dist-js/ # Transpiled output from guest-js
├── android/ # Android library (optional)
└── ios/ # Swift package (optional)
The two most important decisions when creating the project are:
- Whether to include a frontend NPM package. Without it, the plugin can only be consumed from Rust. With it, you ship a typed JavaScript API that other frontend code imports directly.
- Whether to add mobile support (
--android,--ios). If your plugin will eventually run on mobile, including the mobile directories early avoids later restructuring.
A plugin crate must respect the tauri-plugin- prefix; the Tauri community and tooling rely on this convention for discovery and automatic configuration.
The plugin prefix matters:
The tauri-plugin- prefix is not just a suggestion. The Tauri CLI uses it to detect plugins, and the permissions system expects plugin names to match. If you name your crate differently, you will have to manually wire permissions and configuration, which is error-prone.
Configuration
Every plugin can accept configuration from the application’s tauri.conf.json under the plugins key. The plugin author defines a Config struct and provides it to the Builder. At runtime, the application’s configuration is deserialized into this struct:
use serde::Deserialize;
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
#[derive(Deserialize)]
pub struct Config {
pub timeout: usize,
}
pub fn init<R: Runtime>() -> TauriPlugin<R, Config> {
Builder::<R, Config>::new("plugin-name")
.setup(|app, api| {
let timeout = api.config().timeout;
// Use timeout to configure plugin behavior
Ok(())
})
.build()
}
In the consuming app, the configuration lives under the plugin’s name:
{
"plugins": {
"plugin-name": {
"timeout": 30
}
}
}
Mismatched config keys silently fail:
If the application’s JSON contains a key not present in the Config struct, or vice versa, Serde will either ignore it or fail deserialization depending on the #[serde(deny_unknown_fields)] attribute. The plugin won't receive the value, and no error appears unless you explicitly log it. Always validate the config in setup().
Lifecycle Hooks
Plugins can subscribe to five lifecycle events. Each hook solves a distinct timing problem:
| Hook | Trigger | What it enables |
|---|---|---|
setup | Plugin initializes | Register state, start background tasks, read config |
on_navigation | Webview navigates | Validate URLs, track page changes |
on_webview_ready | New window created | Execute init scripts per window |
on_event | Event loop events | Handle exit, window events, menu clicks |
on_drop | Plugin deconstructed | Cleanup file handles, save state |
setup is where most plugin state is initialized. on_event is especially important for applications that need to intercept exit requests or save state before the process terminates. on_drop runs when the plugin is dropped, not necessarily when the app exits—so critical cleanup on exit should use the RunEvent::Exit branch in on_event.
use tauri::{
plugin::Builder,
Manager, RunEvent,
};
use std::{
collections::HashMap,
sync::Mutex,
time::Duration,
};
struct DummyStore(Mutex<HashMap<String, String>>);
Builder::new("plugin-name")
.setup(|app, _api| {
app.manage(DummyStore(Default::default()));
let app_handle = app.clone();
std::thread::spawn(move || loop {
app_handle.emit("tick", ());
std::thread::sleep(Duration::from_secs(1));
});
Ok(())
})
.on_event(|app, event| {
if let RunEvent::Exit = event {
let store = app.state::<DummyStore>();
// Persist store to disk before exit
}
})
.build();
This example starts a background thread that emits a tick event every second and saves the plugin’s state when the app exits. The setup hook spawns the thread, and on_event catches the exit moment.
State managed in setup is accessible everywhere:
Once you call app.manage() inside setup, any command or other lifecycle hook can access that state via State<YourType>. This is the standard pattern for plugin-wide state that survives across commands.
Registering Commands
Commands are the primary interface between your plugin’s Rust code and the frontend. A command is any function annotated with #[tauri::command]. Once registered, the frontend can call it via invoke('plugin:<name>|<command>', { ... }). The Registering Commands page covers permissions and the React invoke path in full.
Defining Commands
Place command functions in src/commands.rs (or keep them in lib.rs for small plugins). Each command follows the same rules as any Tauri command: parameters must implement Deserialize, the return type must implement Serialize, and async commands return a Result.
use tauri::{AppHandle, Runtime, State};
use crate::{MyState, Result};
#[tauri::command]
pub async fn execute<R: Runtime>(
_app: AppHandle<R>,
state: State<'_, MyState>,
input: String,
) -> Result<String> {
let mut data = state.0.lock().unwrap();
data.insert("last_input".into(), input.clone());
Ok(format!("processed: {}", input))
}
This command accepts a string, stores it in plugin-managed state, and returns a processed version. The State<MyState> injection works because MyState was registered via app.manage() in the plugin’s setup.
Wiring Commands into the Plugin Builder
In lib.rs, collect all commands with tauri::generate_handler! and attach them to the plugin builder:
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
use crate::commands;
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::<R>::new("my-plugin")
.invoke_handler(tauri::generate_handler![
commands::execute,
])
.build()
}
Commands not registered in build.rs will fail silently:
Since Tauri v2, every plugin must also declare its commands at compile time via build.rs in the consuming application. Without this, the command is not recognized by the permission system and any invoke() will fail with "command not allowed. Plugin not found." This is the single most common mistake when creating a custom plugin.
build.rs Manifest Registration
In the application that uses the plugin, not inside the plugin itself, you must add a build.rs that registers the plugin's commands. This tells the Tauri builder what permissions the plugin needs before the app compiles.
fn main() {
tauri_build::try_build(
tauri_build::Attributes::new()
.plugin(
"my-plugin",
tauri_build::InlinedPlugin::new()
.commands(&["execute"]),
),
)
.expect("failed to run tauri-build");
}
Without this step, the commands will compile but the frontend will never be allowed to call them. The error message is cryptic: "Connection failed: my-plugin.command not allowed." If you encounter that, check build.rs first.
Permissions and Capabilities
Tauri v2’s security model requires explicit permissions for every plugin command. Permissions are defined in the plugin’s permissions/ directory and granted in the application’s capabilities file. The plugin template auto-generates a default permission set, but you often need to tailor it.
A plugin permission file like permissions/default.toml might contain:
[default]
description = "Default permissions for the plugin"
permissions = ["allow-execute"]
In the consuming application’s capabilities file, you then grant the plugin’s permissions:
{
"identifier": "default",
"description": "Default capabilities for the app",
"windows": ["main"],
"permissions": [
"core:default",
"my-plugin:allow-execute"
]
}
The permission string my-plugin:allow-execute follows the pattern <plugin-name>:<permission>. Each #[command] in your plugin typically generates a corresponding allow-<command-name> permission that you must grant.
Missing capability means silent failure at runtime:
If the capability file does not include the required permission, the frontend call to invoke() will appear to hang or fail with an ACL rejection. No build error or compile-time warning will catch this. Always test your plugin with the exact capability configuration you intend to ship.
You can also register capabilities dynamically in the plugin’s setup using CapabilityBuilder, but the recommended approach for most plugins is the static file-based configuration. It keeps the permission policy explicit and reviewable.
Frontend Integration
To give your plugin a polished frontend experience, create JavaScript bindings in guest-js/. These bindings wrap the raw invoke() calls into typed, documented functions. Your plugin’s package.json exports whatever is in dist-js/, so consumers import from the plugin’s NPM package.
import { invoke } from '@tauri-apps/api/core';
export async function execute(input: string): Promise<string> {
return invoke('plugin:my-plugin|execute', { input });
}
The invocation string uses the format plugin:<plugin-name>|<command>. This namespace prevents collisions with commands from other plugins or the application itself.
A consumer application then uses the plugin:
import { execute } from 'my-plugin-api';
function App() {
async function handleClick() {
const result = await execute('hello from UI');
console.log(result); // "processed: hello from UI"
}
return <button onClick={handleClick}>Run Plugin Command</button>;
}
The consumer imports from the NPM package name you defined, not from a local path. The Rust plugin is added via .plugin() in the Tauri builder, and the JavaScript package is installed as a regular dependency.
Sharing Plugins Between Projects
A plugin is meant to be shared. Tauri’s plugin architecture supports several distribution strategies, from local path dependencies to published crates. See Sharing Plugins Between Projects.
Using a Local Plugin
During development, the quickest way to use a plugin is to point the application’s Cargo.toml at the local directory:
[dependencies]
tauri-plugin-my-plugin = { path = "../tauri-plugin-my-plugin" }
For the NPM package, use a local file reference:
{
"dependencies": {
"my-plugin-api": "file:../tauri-plugin-my-plugin"
}
}
This keeps the plugin and application in the same repository, which is ideal while the plugin’s API is still changing.
Publishing to Registries
When the plugin stabilizes, publish the Rust crate to crates.io and the NPM package to the npm registry:
# Inside the plugin directory
cd tauri-plugin-my-plugin
cargo publish
npm publish
Consumers then depend on versioned releases:
[dependencies]
tauri-plugin-my-plugin = "1.0"
{
"dependencies": {
"my-plugin-api": "^1.0.0"
}
}
Publishing the plugin does not publish the permissions automatically:
Permission files live in the plugin’s source repository, but the consuming application still needs to include them in its capabilities. Some plugins ship a permissions/ directory that the consumer copies or references. Document this clearly in your plugin’s README, because it’s a frequent point of confusion.
Private Registries and Git Dependencies
For internal teams, publishing to a private crate registry or directly referencing a Git repository is common:
[dependencies]
tauri-plugin-my-plugin = { git = "https://github.com/org/repo", branch = "main" }
The same pattern works for NPM via git+https:// URLs in package.json. This avoids the overhead of publishing while still sharing the plugin across multiple projects.
Keeping the Plugin in Sync
A shared plugin introduces a versioning concern. When the plugin’s API changes (new commands, modified signatures, additional configuration keys), every application using it must update its capability file, build.rs, and possibly the frontend code. Backward-incompatible changes should ship as a major version bump. This is standard dependency management, but Tauri’s permission layer adds an extra dimension: a new command means a new permission string that applications must explicitly grant.
A well-documented migration guide pays for itself quickly:
If your plugin will be used by multiple teams, include a CHANGELOG.md that explicitly lists any new permissions or changes to the configuration schema. That single file prevents hours of debugging "command not allowed" errors across the organization.
Summary
A custom Tauri plugin bundles Rust commands, lifecycle hooks, and optional frontend bindings into a reusable package. The structure is standardized: a tauri-plugin- crate, a guest-js/ directory for the JavaScript API, and permission files that define exactly what the plugin is allowed to do. The most critical lesson from building a plugin is that three separate systems must agree: the Rust command registration, the build.rs manifest, and the application’s capability permissions. When any of those three is missing or inconsistent, the command is silently blocked at runtime. No compiler will warn you—you discover it when invoke() fails. Understanding that triangle is the difference between a working plugin and hours of confusion.
A plugin that has only Rust commands and no lifecycle hooks can often start as an in-app module and graduate to a plugin later. But if you know from the start that the code will be used in multiple applications, building it as a plugin from day one avoids re-architecture pain. The next step from here is to explore the mobile plugin development guide if your plugin needs Android or iOS support, or dive into the official plugin workspace to study real-world plugins like global-shortcut or http as reference implementations.
Why Create Plugins?
Understand the motivations and benefits of building custom plugins in Tauri v2, from code organization to sharing and lifecycle integration.
Plugin Structure
A detailed breakdown of every folder and file in a Tauri v2 plugin project, what each part does, and how they fit together.
Registering Commands in a Plugin
How to define Rust commands, register them in a custom Tauri v2 plugin, configure permissions, and invoke them from a React frontend
Sharing Plugins Between Projects
Build reusable Tauri v2 plugins as independent Rust crates and NPM packages, then share them across multiple applications either locally or via public registries.