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.

A plugin that lives in a single project stops being a plugin and becomes just another module. The real power of the plugin system is that you build a feature once—a file system tool, a database layer, a custom authentication flow—and use it in every Tauri app you maintain, or even share it with the community. This guide walks through packaging a Tauri v2 plugin so other projects can depend on it, from the first cargo new to publishing on registries and wiring it into a consumer app with a React + Vite frontend. The Plugin Structure page is the layout this packaging assumes.

What Makes a Plugin Shareable?

A Tauri plugin is considered reusable when it exists as a separate Rust crate and an accompanying NPM package, completely independent of any application project. The crate exposes commands through Tauri's plugin infrastructure; the NPM package wraps those commands in a typed JavaScript API that the frontend can call directly.

Inline plugins—where you define a plugin's logic inside the same crate as your application, often by calling Builder::new in lib.rs—are convenient for organizing internal code. They are not a sharing mechanism. In Tauri v2, inline plugins require modifying build.rs to include the permission manifest manually, and they cannot be imported by another project without copying source files, which quickly breaks when permissions and APIs diverge.

Inline Plugins Are Not for Reuse:

Copying inline plugin code between projects leads to permission mismatches and missing manifests. Tauri v2 was designed with external plugins as the primary pattern for sharing functionality. If you want a plugin to be used in more than one place, create it as a separate crate.

A shareable plugin project therefore contains two distinct packages: a Cargo crate (the Rust side) and an NPM package (the JavaScript bindings). Both must be published or linked so that a consumer app can pull them in like any other dependency.

Creating a Reusable Plugin

The steps below build a small plugin called greeter that exposes a single command: greet(name: String) -> String. You can replace the logic with your own feature once you understand the flow.

1

Step 1: Initialize the Plugin Project

Use the Tauri CLI to scaffold the plugin skeleton. This generates the directory layout, Cargo metadata, an optional NPM package, and permission scaffolding.

npx @tauri-apps/cli plugin new greeter

The command creates a directory named tauri-plugin-greeter with this structure:

tauri-plugin-greeter/
├── src/
│   ├── commands.rs        # Where your command implementations live
│   ├── desktop.rs         # Desktop-specific logic
│   ├── error.rs           # Custom error type
│   ├── lib.rs             # Plugin entry point
│   ├── mobile.rs          # Mobile-specific logic
│   └── models.rs          # Shared data structures
├── permissions/           # Permission definitions for the plugin
├── guest-js/              # TypeScript source for the JS bindings
├── dist-js/               # Compiled JS output (created after build)
├── Cargo.toml
└── package.json

If you plan to support mobile, add the --android and --ios flags. For a desktop-only plugin, the default is sufficient.

2

Step 2: Write the Rust Implementation

Open src/commands.rs and replace the placeholder with a real command. The greet function takes a name from the frontend and returns a greeting string.

use tauri::command;
#[command]
pub fn greet(name: String) -> String {
    format!("Hello, {}! From the greeter plugin.", name)
}

The src/error.rs file provides a default error type that works well for most plugins. You can leave it as generated or customize it when your plugin needs to surface structured errors.

The plugin entry point lives in src/lib.rs. It creates a TauriPlugin using the Builder, attaches the command handler, and returns it so the consumer app can register it.

use tauri::{
    plugin::{Builder, TauriPlugin},
    Runtime,
};
mod commands;
mod error;
mod mobile;
mod desktop;
pub use error::{Error, Result};
/// Initializes the greeter plugin.
pub fn init<R: Runtime>() -> TauriPlugin<R> {
    Builder::new("greeter")
        .invoke_handler(tauri::generate_handler![commands::greet])
        .build()
}

The string "greeter" passed to Builder::new is the plugin's identifier. It determines how commands are namespaced and how the plugin is referenced in tauri.conf.json. For a plugin named greeter, the full command name becomes plugin:greeter|greet.

The Plugin Identifier Matters:

The identifier must match the name used in your NPM package and in the consumer app's plugin registration. Stick to lowercase alphanumeric characters and hyphens to avoid surprises across different operating systems.

3

Step 3: Build the JavaScript API

The guest-js directory holds the TypeScript source that becomes the NPM package. Open guest-js/index.ts and define a function that calls the Rust command through Tauri's invoke.

import { invoke } from '@tauri-apps/api/core';
/**
 * Sends a name to the Rust backend and gets a greeting back.
 * @param name - The name to greet.
 * @returns A greeting string.
 */
export async function greet(name: string): Promise<string> {
    return await invoke('plugin:greeter|greet', { name });
}

The command string plugin:greeter|greet follows Tauri's convention: plugin:<identifier>|<command_name>. This ensures that even if another plugin exposes a greet command, there is no collision.

Next, configure the NPM package in package.json. Use a scoped name to follow the Tauri convention and avoid name squatting on the public registry.

{
    "name": "@my-scope/tauri-plugin-greeter",
    "version": "0.1.0",
    "main": "dist-js/index.js",
    "types": "dist-js/index.d.ts",
    "scripts": {
        "build": "tsc"
    },
    "dependencies": {
        "@tauri-apps/api": "^2.0.0"
    },
    "devDependencies": {
        "typescript": "^5.0.0"
    }
}

Add a minimal tsconfig.json to guest-js/:

{
    "compilerOptions": {
        "target": "ES2020",
        "module": "ESNext",
        "moduleResolution": "bundler",
        "strict": true,
        "outDir": "../dist-js",
        "declaration": true,
        "declarationMap": true,
        "sourceMap": true
    },
    "include": ["./**/*.ts"]
}

Build the bindings once to confirm everything compiles:

npm run build

The compiled files appear in dist-js/. This output is what consuming projects will actually import.

4

Step 4: Define Plugin Permissions

In Tauri v2, every command needs explicit permission from the consumer app. The plugin ships permission files so that app developers can see what is required and opt in. Create a file permissions/default.toml that grants access to the greet command.

[default]
description = "Default permissions for the greeter plugin"
[[permission]]
identifier = "greeter:default"
description = "Allows the greet command"
commands.allow = ["greet"]

The identifier greeter:default follows the pattern <plugin-name>:default. When a consumer app adds this string to its capability file, the greet command becomes available.

The plugin's build.rs can remain as simple as the generated template:

fn main() {
    tauri_build::build()
}

Tauri's build script detects the permissions/ directory automatically and embeds the permission definitions into the plugin binary. No manual configuration is needed.

5

Step 5: Test the Plugin in Isolation

Every plugin project includes a small Tauri application inside the examples/ directory (or you can create one). Use it to verify that the command works before sharing the plugin with other projects.

In the example app's lib.rs, register the plugin:

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

In the capability file for the example app, include "greeter:default":

{
    "identifier": "default-capability",
    "windows": ["main"],
    "permissions": [
        "core:default",
        "greeter:default"
    ]
}

Call the command from the frontend (a plain HTML page or a React component):

import { greet } from '../../../guest-js/index.ts';
greet('Tauri').then(console.log);

Everything Wired Correctly:

If the console prints Hello, Tauri! From the greeter plugin., the plugin is working end-to-end. The same steps will be repeated in any consumer app that adopts it.

Sharing the Plugin Locally

Before publishing to a public registry, you often share plugins between projects on your own machine—for instance, when building a suite of internal tools that share a common data-access plugin.

Rust side: In the consumer app's Cargo.toml, point the dependency to the local path.

[dependencies]
tauri-plugin-greeter = { path = "../../tauri-plugin-greeter" }

JavaScript side: In the consumer app's package.json, use a file: protocol to reference the plugin's root directory.

"dependencies": {
    "@my-scope/tauri-plugin-greeter": "file:../tauri-plugin-greeter"
}

Run npm install to create the symlink. Cargo and npm will now resolve the plugin from your local filesystem. Any change you make to the plugin is reflected immediately in the consumer app after a rebuild—no publish step required during development.

Using npm link as an Alternative:

Instead of file:, you can run npm link inside the plugin's directory and then npm link @my-scope/tauri-plugin-greeter in the consumer app. The effect is similar, but file: is simpler when both projects live on the same machine and you want version control to track the exact path.

Publishing to Registries

When the plugin is stable and you want to share it publicly or across team machines without path dependencies, publish both the crate and the NPM package.

Cargo crate (crates.io):

  1. Make sure Cargo.toml includes a description, license, and repository field. These are required for publishing.
  2. Log in with cargo login.
  3. Run cargo publish from the plugin's root.

NPM package (npm registry):

  1. Update package.json with a description, license, and repository if missing.
  2. Build the JavaScript bindings: npm run build.
  3. Log in with npm login.
  4. Run npm publish --access public (the --access public is required for scoped packages the first time).

Version both the crate and the NPM package together. Semantic versioning helps consumers understand the impact of updates. A breaking change in the Rust API should be accompanied by a major version bump in both packages, along with clear migration notes.

Using a Shared Plugin in a Tauri App

Assume the plugin is now available—either from a registry or via a local path. The consumer app is a standard Tauri v2 project with React + Vite as the frontend. Here is how to integrate the greeter plugin.

1. Add the Rust dependency and register the plugin.

In src-tauri/Cargo.toml, add the dependency (with the correct version or path):

[dependencies]
tauri-plugin-greeter = "0.1.0"

Register the plugin in src-tauri/src/lib.rs:

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

2. Install the JavaScript bindings.

npm install @my-scope/tauri-plugin-greeter

3. Add the plugin's permissions to a capability file.

Every command in Tauri v2 must be explicitly allowed. The plugin ships a permission set greeter:default. Add it to the capability assigned to the windows that need it.

{
    "identifier": "main-capability",
    "description": "Capability for the main window",
    "windows": ["main"],
    "permissions": [
        "core:default",
        "greeter:default"
    ]
}

4. Call the command from the React frontend.

import { useState } from 'react';
import { greet } from '@my-scope/tauri-plugin-greeter';
function App() {
    const [message, setMessage] = useState('');
    const handleGreet = async () => {
        const msg = await greet('World');
        setMessage(msg);
    };
    return (
        <div>
            <button onClick={handleGreet}>Greet</button>
            <p>{message}</p>
        </div>
    );
}
export default App;

When the button is clicked, the string Hello, World! From the greeter plugin. appears on screen. No extra configuration is needed beyond the steps above.

Forgotten Permissions Cause Silent Failures:

If the capability file does not include greeter:default, the command is blocked by Tauri's runtime. The JavaScript call will return an error, and the console will show a permission rejection. This is the most common integration mistake. Always check the capability configuration first when a plugin command does not work.

Designing Plugin Configuration

Many plugins need user‑specific settings—database URLs, timeouts, feature flags. Tauri allows plugins to read configuration from the plugins section of tauri.conf.json.

In the plugin's src/lib.rs, define a configuration struct and read it during setup:

use serde::Deserialize;
use tauri::{
    plugin::{Builder, TauriPlugin},
    Runtime,
};
#[derive(Deserialize)]
pub struct Config {
    pub prefix: Option<String>,
}
pub fn init<R: Runtime>() -> TauriPlugin<R, Config> {
    Builder::<R, Config>::new("greeter")
        .setup(|app, api| {
            let prefix = api.config().prefix.clone().unwrap_or_else(|| "Hello".into());
            // Store the prefix in managed state so commands can access it
            app.manage(prefix);
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![commands::greet])
        .build()
}

A consumer app can then provide the configuration:

{
    "plugins": {
        "greeter": {
            "prefix": "Hi"
        }
    }
}

The command would then retrieve the prefix from the managed state instead of hardcoding it. This pattern keeps plugins generic while allowing each project to customize behavior.

Common Pitfalls

Version Mismatch Between Plugin and App:

A plugin compiled against Tauri 2.0.0 will not load in an app running Tauri 2.1.0 if there are breaking changes in the plugin interface. Always keep the major and minor versions aligned, or specify version ranges carefully in Cargo.toml.

Permission Manifest Not Found:

The error Plugin did not define its manifest usually means the plugin was built without embedding its permissions. If you are experimenting with an inline plugin, you need a custom build.rs that calls tauri_build::try_build with the correct permissions path. External plugins handle this automatically because the build script is part of the plugin crate, which is consumed as a dependency.

JavaScript Bindings Out of Sync:

If you add a new command to the Rust side but forget to rebuild the dist-js directory, the consumer app will see an outdated API. Always run npm run build in the plugin after changing the set of commands or their signatures.

Hardcoded Paths in the Plugin:

Plugins must never assume a fixed directory layout on the user's machine. If your plugin reads or writes files, derive paths from App::path() or accept them as command parameters. A plugin that uses std::env::current_dir() will break the moment it is used in a project with a different working directory.

Best Practices

  • Ship minimal default permissions. Only allow commands that are safe for general use. Commands that access the filesystem or network should be placed in separate permission sets that the consumer opts into explicitly.
  • Document the configuration schema. Include a commented example in the plugin's README so that developers know what keys are available and what they do.
  • Version with intent. Bump the major version when you rename a command, change its signature, or alter the required permissions. Consumers rely on semantic versioning to decide when to upgrade.
  • Test across platforms early. A plugin that works on macOS may fail on Windows due to path separators or missing APIs. Use CI to build and test on all target platforms before publishing.
  • Keep the JavaScript API thin. The frontend binding should only serialize arguments and call invoke. Complex logic belongs in Rust, where it is faster and more secure.

Summary

A shareable plugin is the foundation for building a Tauri ecosystem. Once you are comfortable distributing plugins, you can share them across your organization. If you need mobile support, the Mobile Plugin Development guide extends the pattern shown here to Kotlin and Swift.