Why Create Plugins?

Understand the motivations and benefits of building custom plugins in Tauri v2, from code organization to sharing and lifecycle integration.

A Tauri application by default gives you a webview, a Rust backend, and a handful of core APIs. That is deliberately minimal. Most real applications need more—a database layer, system tray control, custom file parsing, or any number of domain-specific native operations. Plugins are how you add those capabilities without turning your application into an unmaintainable monolith.

What a Tauri Plugin Is

A plugin is a self-contained Rust crate that extends Tauri’s runtime with new commands, events, lifecycle hooks, and managed state. It can optionally include a JavaScript or TypeScript package that provides typed bindings for your frontend code. On mobile, a plugin can also bundle platform-specific code written in Kotlin for Android or Swift for iOS.

When your application registers a plugin, Tauri integrates its commands and permissions just like it does with any official plugin. The frontend can then call those commands through invoke() or the generated bindings, and the Rust side can emit events back to the webview. This is the same mechanism used by plugins like store, sql, or opener.

Why Not Just Write Everything in the Main Crate

The simplest Tauri project puts a few #[tauri::command] functions in src-tauri/src/main.rs or lib.rs, registers them with .invoke_handler(), and calls it a day. For a tiny prototype that works. Problems appear when the application grows. If you only need a few commands in one app, Creating Your First Rust Command is the simpler path.

Code Organization Breaks Down

A single file of commands for file handling, authentication, analytics, and database operations quickly becomes thousands of lines with no natural boundaries. Adding a new feature means scrolling past unrelated code. Fixing a bug in one command risks breaking another because they share mutable state in unstructured ways.

A plugin enforces a boundary. Its public API is a set of commands and events. Its internal state lives inside the plugin struct and is managed through Tauri’s state system. Everything the plugin needs is grouped in one crate with a clear entry point.

Separation of Concerns:

Plugins let you think about one feature at a time. The database plugin handles queries; the updater plugin handles release checks. Each can be developed, tested, and debugged independently.

No Clear Path to Reuse

Custom commands written in the main crate live in that crate forever. If you build a second Tauri application that needs the same native logic—say, a custom hardware monitoring command—you copy and paste the code, then maintain it in two places. That duplication compounds with every new project.

Packaging that logic as a plugin makes it a reusable crate. You can publish it to crates.io and npm, or simply reference it locally across multiple projects. The plugin’s permissions, commands, and JavaScript bindings travel together, so the consumer gets a consistent interface.

Lifecycle Integration Is Manual

Tauri provides lifecycle hooks: setup, navigation, webview ready, event loop events, and drop. Without a plugin, hooking into these means adding closures to the Builder in your main setup. That works, but it scatters lifecycle logic across an already busy initialization block.

A plugin can register its own lifecycle handlers internally. When a new window is created, the plugin runs its on_webview_ready code. When the event loop receives an exit request, the plugin can save state before the process terminates. The main crate does not need to know these details exist.

Permission Handling Is More Structured in v2

Tauri v2 requires every command to have an associated permission that the application must explicitly grant. When you define commands ad hoc, you also need to manually add entries in capabilities/default.json. As the number of commands grows, the permission file becomes a long, unannotated list with no obvious grouping. See Understanding Capabilities.

A plugin bundles its permission definitions. Running tauri plugin init generates a permissions/ directory with auto-generated permission files for each command. The application still needs to grant those permissions, but the grouping is provided by the plugin author. This means a consumer of your plugin knows exactly which permissions to enable without guessing.

Inline Plugins Require Extra Setup:

If you create a plugin that lives inside the same workspace as your application (an inline plugin), Tauri v2 cannot automatically discover its permissions at build time. You must modify build.rs to point to the plugin's permissions directory, similar to how the official examples handle it. Forgetting this step causes the dreaded "Plugin did not define its manifest" error.

When Building a Plugin Makes Sense

The decision to extract functionality into a plugin is not always automatic. The following scenarios are strong indicators that a plugin will pay off.

You Are Writing More Than a Handful of Commands

A single command to read a config file does not justify a plugin. Five commands that manage a custom key-value store, emit events when values change, and persist to disk on exit do. When the Rust side of a feature starts to feel like its own module, it is probably ready to become a plugin.

You Expect to Use the Same Logic in Another Project

Any native capability that is not tied to a specific application’s business logic—logging, telemetry, hardware interaction—is a candidate for a reusable plugin. Even if you do not plan to open-source it, your own organization benefits from having one crate that multiple applications import.

You Need Platform-Specific Native Code

A plugin project scaffolded with --android and --ios flags includes an Android library project and a Swift package. This lets you call Kotlin or Swift functions from Rust, and ultimately from your React frontend, without leaving the plugin structure. The alternative—manually wiring JNI or Swift interop into the main crate—is error-prone and tightly coupled to a single application.

You Want to Contribute to the Tauri Ecosystem

Official plugins like store, sql, and updater started as community needs. Building your own plugin, publishing it, and maintaining it helps other developers solve the same problem. The Tauri community has established conventions (tauri-plugin- prefix, permission patterns) that make your plugin discoverable and easy to adopt.

A Well-Structured Plugin Is Self-Documenting:

When another developer opens your plugin crate, they see the commands.rs, lib.rs, and permissions/ directory. The structure itself answers "what does this plugin do and what permissions does it need?" before they read a single line of documentation.

What a Plugin Gives You Beyond Code Splitting

Creating a plugin is more than renaming a directory. It connects you to several Tauri subsystems that are harder to access from raw command registration.

Lifecycle Hooks

A plugin can implement setup to initialize state, on_navigation to validate or track URL changes, on_webview_ready to inject scripts into every window, on_event to intercept application-level events like exit requests, and on_drop for cleanup. These hooks run in a predictable order and are scoped to the plugin, so two plugins do not interfere with each other’s lifecycle logic.

Managed State Without Global Variables

Plugins can register state through Tauri’s manage() method during setup. That state is then available to all the plugin’s commands through State<T>. Because Tauri owns the state’s lifetime, it is automatically cleaned up when the application exits. No manual lazy_static or OnceCell needed.

Auto-Generated JavaScript Bindings

The guest-js directory in a plugin project contains the TypeScript source for the frontend bindings. When you build the plugin, it transpiles this into dist-js, which becomes the NPM package that your React application imports. This means your frontend code calls typed functions like await myPlugin.doSomething(args) instead of raw invoke("plugin:my_plugin|do_something", { ... }) with untyped payloads.

Unified Permission Model

Every command in a plugin comes with a permission identifier like my-plugin:allow-do-something. The application’s capability file grants or denies these permissions. This integrates with Tauri’s security model: commands that access the file system, network, or system APIs must be explicitly allowed, and a plugin makes those requirements explicit rather than forcing the application developer to guess which permissions are needed.

Missing Permissions Silently Fail:

If your plugin defines a command but the application does not grant the corresponding permission, the frontend call will fail with a "not allowed" error. This is a Tauri v2 security feature, not a bug, but it surprises developers migrating from v1 where commands were implicitly available. Always test your plugin with the permissions you expect the consumer to add.

A Concrete Example: Before and After

Consider a feature that stores the last window size and position so the application can restore it on the next launch. Without a plugin, you might add the logic directly to the main crate.

Without a plugin — all logic in the main crate:

use tauri::Manager;
use serde::{Deserialize, Serialize};
use std::fs;
#[derive(Debug, Serialize, Deserialize)]
struct WindowState {
    x: i32,
    y: i32,
    width: u32,
    height: u32,
}
#[tauri::command]
fn save_window_state(state: tauri::State<'_, WindowState>, path: String) -> Result<(), String> {
    let json = serde_json::to_string(&*state).map_err(|e| e.to_string())?;
    fs::write(&path, json).map_err(|e| e.to_string())
}
fn main() {
    tauri::Builder::default()
        .manage(WindowState { x: 0, y: 0, width: 800, height: 600 })
        .invoke_handler(tauri::generate_handler![save_window_state])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

This works, but the command, the state struct, and the serialization logic all sit in the entry point file. A second command for loading state would double the clutter. There is no permission file—you add the command to capabilities manually. Moving this to a second project means copying code.

With a plugin — encapsulation and reuse:

The plugin crate contains the state struct, the commands, and the plugin builder in one place.

use serde::{Deserialize, Serialize};
use std::fs;
use tauri::{
    plugin::{Builder, TauriPlugin},
    Runtime,
};
#[derive(Debug, Serialize, Deserialize)]
pub struct WindowState {
    pub x: i32,
    pub y: i32,
    pub width: u32,
    pub height: u32,
}
#[tauri::command]
fn save_window_state(state: tauri::State<'_, WindowState>, path: String) -> Result<(), String> {
    let json = serde_json::to_string(&*state).map_err(|e| e.to_string())?;
    fs::write(&path, json).map_err(|e| e.to_string())
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new("window-persist")
        .setup(|app, _api| {
            app.manage(WindowState {
                x: 0,
                y: 0,
                width: 800,
                height: 600,
            });
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![save_window_state])
        .build()
}

In the application, registration is a single line:

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_window_persist::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

The application crate no longer knows what WindowState looks like or how it is serialized. The plugin owns that concern completely. If you build a second Tauri application, you add the same .plugin() line and grant the permissions. No copy-paste.

From the React frontend, the usage remains simple through invoke or, once you generate bindings, a typed import:

import { invoke } from "@tauri-apps/api/core";
async function savePosition(x: number, y: number) {
    await invoke("plugin:window-persist|save_window_state", {
        state: { x, y, width: 1024, height: 768 },
        path: "/path/to/state.json",
    });
}

The command name includes the plugin prefix, which prevents collisions with commands from other plugins or the main crate. This namespace isolation becomes critical in larger projects with multiple plugins from different sources.

Common Misconceptions About Plugins

Several assumptions trip up developers new to Tauri’s plugin system, especially those coming from v1 or from other ecosystems.

“Plugins are only for open-source sharing.”
The primary value of a plugin is architectural. You can keep a plugin entirely private within your monorepo and still gain all the organizational benefits described above. Sharing is an option, not a requirement.

“Creating a plugin is overkill for a few commands.”
For two commands with no shared state, maybe. But the threshold is lower than most developers think. A plugin template generated by tauri plugin new gives you the crate structure, permission files, and build configuration in seconds. The cost of starting a plugin is so low that it is often worth doing even for medium-sized feature modules.

“Inline plugins work exactly like v1.”
They do not. As noted in the warning earlier, v2’s permission system requires explicit generation of permission manifests. For inline plugins (plugins that live in the same workspace but are not published), you must update build.rs to include the plugin’s permissions directory. Without this, the application cannot discover the plugin’s commands at runtime. This is a known friction point that the Tauri team is aware of and may improve in future releases.

Build Script Requirement for Inline Plugins:

If your plugin is not a published crate, add the following to your application’s build.rs:

tauri_build::build_with_config(tauri_build::BuildConfig {
    plugin_permissions: &["../plugins/window-persist/permissions"],
    ..Default::default()
});

This tells Tauri where to find the plugin’s auto-generated permission files. Without it, you will see runtime permission denials even though the plugin is registered.

How Beginners Should Think About Plugins

If you are new to Tauri, imagine your application as a house. The Tauri core is the foundation, walls, and roof—the structure that everything else depends on. A plugin is a self-contained room you add to the house. It has its own door (the public API), its own wiring (lifecycle hooks), and its own purpose. You can add a kitchen (database plugin), a bathroom (authentication plugin), or a garage (file storage plugin) without rebuilding the whole house.

When you write a custom command directly in your main crate, it is like building a stove in the living room. It works, but it is hard to maintain, impossible to move to another house, and confusing to anyone else who visits.

This mental model applies even if you never intend to share your plugin. The isolation and clear interface make your own reasoning about the code easier six months from now.

Summary

Plugins are Tauri’s answer to the problem every non-trivial application faces: how to add capabilities without creating a tangled codebase. They provide a defined structure for commands, state, events, and permissions. They make native code reusable across projects. They integrate with Tauri’s lifecycle and permission system in a way that ad hoc command registration cannot match.

The decision to create a plugin should be driven by the complexity and scope of the feature you are building, not by whether you plan to publish it. A plugin that lives only in your private repository still delivers organizational value every time you need to change, test, or extend that feature.