Introduction to Plugins

Learn how Tauri plugins extend your desktop application with native functionality, how to install and configure them, and best practices for using plugins in your Tauri v2 projects.

Tauri v2 ships with a deliberately minimal core. The framework gives you a window, a webview, and a secure bridge between your frontend JavaScript and the Rust backend, but it intentionally leaves out features that not every app needs. File system access, system tray icons, HTTP clients, global shortcuts — none of these are built into Tauri itself. Instead, Tauri provides a mechanism to add exactly the capabilities your app requires: plugins.

A plugin is a reusable piece of Rust code (and often a companion JavaScript package) that extends a Tauri application with new commands, events, native integrations, or lifecycle hooks. This architecture keeps your compiled binary small and your application’s permission surface tight, because you only include what you actually use.

Plugins vs. native APIs:

Tauri’s core “native APIs” (like window, path, or event) are implemented as built-in plugins. The distinction is that external plugins come from the community or from your own codebase, while the built-in ones ship with every Tauri project. Both work the same way under the hood.

What Plugins Are

A Tauri plugin is a combination of a Rust crate (which lives in your src-tauri directory) and, optionally, an npm package that provides convenient JavaScript or TypeScript bindings for the frontend. Together, these two halves allow your React components to call native functionality through a clean, typed interface.

The What are Plugins? page covers how this split differs from built-in APIs. The Rust side of a plugin can:

  • Register new commands that the frontend invokes with invoke()
  • Emit events that the frontend listens to
  • Hook into Tauri’s lifecycle (setup, navigation, window creation, and shutdown)
  • Manage long-lived state that persists across command invocations
  • Interact with operating system APIs that the webview alone cannot reach

The JavaScript side is a thin wrapper around invoke and event listeners. It translates function calls into the command names and parameters the Rust side expects, so you never need to write raw invoke('plugin:command', { payload }) by hand.

Think of a plugin as a specialized module that adds one area of functionality: the Store Plugin gives you a persistent key-value store, the HTTP Plugin lets you make HTTP requests from Rust, and the Updater Plugin handles checking for and downloading app updates. When you add a plugin, you effectively teach your Tauri app a new skill.

Why Tauri Uses a Plugin System

Before Tauri v1, if you wanted every app to have file system access by default, the framework would need to bundle that code into every binary. Most apps don’t need the full surface area of system APIs — a note-taking app might need file system access, but a dashboard for a web service probably does not. Shipping unused native code increases binary size, expands the attack surface, and complicates permission models.

Tauri’s plugin system solves this by making everything opt-in. You declare which plugins your app uses in Cargo.toml (Rust) and package.json (JavaScript), and Tauri’s build tooling links only those pieces into the final executable. This keeps your app lean — a minimal Tauri app is around 3–5 MB, and each plugin adds only the Rust code it actually needs.

It also makes the permission model enforceable. Plugins must declare what they can do (open files, listen on sockets, access the clipboard), and your app’s capability file explicitly grants or denies those plugin permissions. A compromised frontend can’t suddenly start making arbitrary HTTP requests if you never installed the HTTP plugin and granted its allow-fetch permission.

How Beginners Should Think About Plugins

If you are new to desktop development, the plugin system can feel like extra friction. Why not just have everything available from the start? The answer is safety and predictability.

Imagine a smartphone app that, on installation, asks for permission to access your camera, microphone, contacts, and location all at once — even if it’s just a calculator. You would delete it. Tauri plugins work the same way: your app only claims the capabilities it genuinely needs, and users (or operating system reviewers) can see that in the app’s manifest. This model protects your users and helps you avoid shipping dangerous code by accident.

When you’re building a feature, the question to ask is: “Does the operating system need to be involved in this?” If the answer is yes — reading a file, showing a notification, opening a URL — then there is likely a plugin for it. You install the plugin, grant the minimal permissions, and then call it from your React code as if it were any other JavaScript library.

How Plugins Work Under the Hood

A plugin, once installed, registers itself with Tauri’s Builder during app initialization. The How Plugins Work page traces that registration through commands, events, and lifecycle hooks. This registration happens in the Rust entry point (typically src-tauri/src/lib.rs). The plugin’s init() function returns a TauriPlugin struct that Tauri then owns for the lifetime of the app.

During setup, the plugin can:

  • Add Rust structs to Tauri’s managed state (accessible later via app.state())
  • Register commands that become callable from JavaScript via invoke()
  • Spawn background tasks (timers, file watchers, network listeners)
  • Hook into lifecycle events like on_navigation or on_drop

Here is a minimal example of a custom plugin that stores a timeout value from the app’s configuration and provides no commands — it’s purely a configuration reader:

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("timeout")
        .setup(|app, api| {
            let timeout = api.config().timeout;
            // Store the config where other Rust code can find it
            app.manage(TimeoutConfig { value: timeout });
            Ok(())
        })
        .build()
}
pub struct TimeoutConfig {
    pub value: usize,
}

In the main lib.rs, you’d then call .plugin(timeout::init()). The "timeout" string matches the key in tauri.conf.json under plugins.timeout, which is where the app developer writes the actual timeout value.

The frontend never interacts with this plugin directly because it exposes no commands. But other Rust code in your app can retrieve the TimeoutConfig state and use it to enforce timeouts on network calls, file operations, or anything else. This pattern — a plugin that only provides Rust-side configuration and infrastructure — is common for plugins that don’t need a JavaScript API.

Commands and the IPC Bridge

The most common plugin pattern is exposing commands: Rust functions annotated with #[tauri::command] that the frontend calls with invoke(). Each command becomes an IPC message that travels from the webview’s JavaScript thread, through Tauri’s IPC layer, to a Rust thread that executes the function, and the return value travels back as JSON.

Here’s a simple plugin command that returns a greeting:

use tauri::command;
#[command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust.", name)
}

The plugin registers this command in its builder:

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

On the frontend, a companion JavaScript library (or manual invoke call) triggers it:

import { invoke } from '@tauri-apps/api/core';
async function handleClick() {
    const message = await invoke<string>('plugin:greeter|greet', { name: 'Alice' });
    console.log(message); // "Hello, Alice! You've been greeted from Rust."
}

Command naming is automatic:

Tauri prefixes plugin commands with plugin:<plugin-name>|. The greet command above becomes plugin:greeter|greet. You don’t need to remember this when using an official plugin’s JavaScript bindings — the binding handles the prefix internally. But if you ever call invoke directly on a plugin command, you must use the full prefixed name.

Lifecycle Hooks

Plugins can also react to application-level events. Tauri exposes several lifecycle hooks that let a plugin execute code at specific moments without needing any frontend interaction.

  • setup — The plugin is being initialized. This is where you register state, spawn background threads, and read configuration.
  • on_navigation — The webview is about to navigate to a new URL. You can inspect the URL and optionally cancel the navigation. This is useful for enforcing internal routing rules.
  • on_webview_ready — A new window has been created and the webview is initialized. You can attach event listeners to it.
  • on_event — The global event loop dispatches an event. This hook lets you respond to system events like exit requests or window closures.
  • on_drop — The plugin is being deconstructed. Use this to flush buffers, close file handles, or save state before the app exits.

Here’s an example that logs every URL navigation and blocks a forbidden scheme:

use tauri::plugin::Builder;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
    Builder::new("navigation_guard")
        .on_navigation(|window, url| {
            println!("Window {} navigating to {}", window.label(), url);
            // Cancel navigation if the URL uses the "forbidden" scheme
            url.scheme() != "forbidden"
        })
        .build()
}

A beginner can think of lifecycle hooks as event handlers for the app itself rather than for user interactions. You use them to run code when the app starts, when a window appears, or when the user tries to close the last window.

Installing Plugins

Most Tauri plugins consist of two packages: a Rust crate (hosted on crates.io) and an npm package (hosted on the npm registry). The Installing Plugins guide walks through the CLI and manual steps in full. You need both for full functionality. Tauri provides a convenient CLI command that adds both in one step, but you can also install them manually.

Run the tauri add command followed by the plugin name. This edits Cargo.toml, installs the npm package, and updates lib.rs automatically.

npm run tauri add store

If you use a different package manager, substitute npm with yarn, pnpm, or bun. The command works the same way.

After the command finishes, the plugin is registered and ready to use — no manual edits required.

Everything wired up:

After tauri add completes, open src-tauri/src/lib.rs. You should see .plugin(tauri_plugin_store::init()) added automatically. The JavaScript package is also in your package.json.

After installation, a good way to verify that everything is connected is to call a simple command from the frontend. For the store plugin, you could create a store and check that no errors appear:

import { createStore } from '@tauri-apps/plugin-store';
async function verifyStore() {
    const store = await createStore('settings.json');
    await store.set('test_key', 'hello');
    const value = await store.get('test_key');
    console.log(value); // should print "hello"
}

If you see the value printed without permission errors, the plugin is installed correctly.

Configuring Plugins

Many plugins accept configuration that you specify in tauri.conf.json under the plugins key. See Configuring Plugins for how those keys map to the Rust Builder. This is where you set plugin-specific options like timeouts, API keys, custom paths, or feature flags.

The plugin name in the configuration must match the string the plugin’s Rust Builder uses — typically the part after tauri-plugin- (e.g., store for tauri-plugin-store).

{
  "build": { /* ... */ },
  "tauri": { /* ... */ },
  "plugins": {
    "store": {
      "defaultFileName": "app_data.json",
      "autoSave": true
    },
    "http": {
      "timeout": 30,
      "connectTimeout": 10
    }
  }
}

On the Rust side, the plugin reads this configuration during its setup hook via the api.config() method. The example from earlier showed a Config struct that deserialized the plugin’s block automatically. You don’t need to write any extra code to parse tauri.conf.json — Tauri does it for you when you pass the right type to the Builder.

Configuration keys are plugin-defined:

Not every plugin requires configuration. Some, like the opener plugin, work out of the box with no plugins.opener block. Refer to each plugin’s documentation to see which keys it accepts and what their defaults are.

Configuration becomes especially important when you need to restrict plugin behavior globally. For instance, the http plugin can be configured to only allow requests to certain domains. This acts as a second layer of security beyond the capability permissions — even if a capability accidentally allows a broad URL scope, the plugin config can still block it.

Best Practices for Working with Plugins

Plugins simplify cross-platform development, but they also introduce dependencies that can break, conflict, or silently fail if not managed carefully. The Plugin Best Practices page collects the same principles with concrete examples. Following a few principles will save you hours of debugging.

Choose plugins from trusted sources

The Tauri organization maintains a set of official plugins (like store, http, updater, opener, and the Process Plugin) under the tauri-apps GitHub namespace. These are audited, versioned alongside Tauri itself, and battle-tested by the community. When an official plugin exists for your use case, prefer it over a third-party alternative.

If you use a community plugin, check its repository activity, open issues, and compatibility with your Tauri version before committing. A plugin that hasn’t been updated in 12 months may not work with Tauri v2’s latest security model.

Keep plugin versions aligned with Tauri

Plugins published by the Tauri team follow the same major version as Tauri itself. A 2.x plugin works with Tauri 2.x. Mixing major versions — such as using a Tauri v1 plugin with a Tauri v2 app — will cause compilation errors or runtime panics.

In your Cargo.toml, you can specify the version with a caret to automatically receive compatible updates:

[dependencies]
tauri-plugin-store = "2"

For npm packages, use a similar semver range:

{
  "dependencies": {
    "@tauri-apps/plugin-store": "^2.0.0"
  }
}

Audit breaking changes before upgrading:

Even minor version bumps can change permission identifiers or command signatures. When a plugin update arrives, read its changelog before running cargo update. A broken build right before a deadline is often caused by an unnoticed plugin update.

Grant the narrowest permissions possible

Capability files control exactly what a plugin can do, and for which windows. Instead of blindly adding plugin-name:default (which often enables a broad set of commands), prefer to list only the specific command permissions your app needs.

{
  "permissions": [
    "core:default",
    {
      "identifier": "store:allow-set",
      "allow": [{ "path": "$APPDATA/settings.json" }]
    },
    {
      "identifier": "store:allow-get",
      "allow": [{ "path": "$APPDATA/settings.json" }]
    }
  ]
}

This example allows the store plugin to read and write only settings.json in the app data directory. The frontend can’t create arbitrary stores elsewhere, which limits the damage if the webview is ever compromised.

Handle plugin errors gracefully

A plugin command can fail for many reasons: missing permissions, a network timeout, a locked file, or an unsupported platform. Always wrap plugin calls in try/catch and provide user-visible feedback when something goes wrong.

import { createStore } from '@tauri-apps/plugin-store';
import { useState, useEffect } from 'react';
export function useStoreValue(key: string) {
    const [value, setValue] = useState<string | null>(null);
    const [error, setError] = useState<string | null>(null);
    useEffect(() => {
        (async () => {
            try {
                const store = await createStore('settings.json');
                const val = await store.get<string>(key);
                setValue(val ?? null);
            } catch (e) {
                setError(`Failed to read ${key}: ${e}`);
            }
        })();
    }, [key]);
    return { value, error };
}

A network of silent failures — where the plugin rejects a call but the UI acts as if nothing happened — is the quickest way to frustrate users and yourself during debugging. Always surface errors.

Test on every target platform

Plugins that rely on operating system APIs can behave differently on Windows, macOS, and Linux. The opener plugin, for example, uses system-specific commands to launch files and URLs, and iOS/Android restrictions mean it can only open URLs, not file paths.

If your app targets multiple operating systems, test each plugin’s behavior on at least one device per OS. A feature that works flawlessly during development on macOS might panic on Windows because a path format is wrong, or silently do nothing on Linux because a system library is missing.

Mobile platforms restrict some plugins:

The opener plugin’s open_path command is only available on desktop. If your frontend calls it on Android, the promise will reject. Check the plugin’s documentation for a “Supported Platforms” table before relying on a feature across all targets.

Do not overload the IPC bridge

Each invoke call crosses the boundary between the webview’s JavaScript engine and the Rust process. This serialization and deserialization is fast — microseconds for most payloads — but if you call a plugin command thousands of times per second (for example, in a game loop or a live-updating chart), the overhead adds up.

Batch operations where possible. Instead of calling store.set in a loop for 100 keys, collect them into an array and call a bulk set command if the plugin provides one. If it doesn’t, consider whether the Rust side can perform the loop internally so only one IPC round trip happens.

Summary

Tauri’s plugin architecture is more than an extension mechanism — it’s a security boundary, a binary size control, and a code organization pattern all at once. Every plugin you add brings specific native power into your app while forcing you to declare exactly what that power is and who can use it. This discipline pays off as your project grows: you won’t accidentally ship a file system crawler inside a calculator, and your users can inspect your app’s manifest and see that it only does what it says.

The concepts in this introduction — installation, configuration, lifecycle hooks, and permission management — apply to every plugin you’ll encounter, from the official store and http plugins to custom ones you build yourself.

What are Plugins?

Learn what plugins are in Tauri v2, why they exist, how they differ from built-in APIs, and how they extend your React application with native functionality.

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.

Installing Plugins

Step-by-step guide to add official and community Tauri v2 plugins to your React + Vite project, covering Rust dependencies, JavaScript bindings, permissions, version sync, and safe updates

Configuring Plugins

How to configure Tauri v2 plugins in tauri.conf.json, match plugin names to the Rust Builder, and combine plugin config with capability permissions

Plugin Best Practices

How to integrate Tauri plugins into a React and Vite project safely, keep dependencies synchronized, and configure permissions with the principle of least privilege