How to Implement Custom Commands in Tauri: Exposing Rust Functions to the Frontend

To implement custom commands in Tauri, annotate Rust functions with #[command], register them via tauri::generate_handler!, and call them from the frontend using the invoke API.

Tauri commands provide a type-safe bridge between your Rust backend and JavaScript frontend. This guide explains how the tauri-apps/tauri repository implements the command system and how you can leverage it to expose native functionality to your web-based UI.

What Are Tauri Custom Commands?

A Tauri command is a Rust function annotated with the #[command] attribute that becomes callable from JavaScript. At compile time, Tauri's procedural macros expand these functions into wrappers that handle serialization, IPC communication, and error propagation automatically. The system supports synchronous and asynchronous functions, state injection, and custom error types.

The Architecture of Tauri Commands

Understanding the internal flow helps debug issues and leverage advanced features. The command system consists of four distinct layers working together.

The #[command] Macro Expansion

When you annotate a function with #[command], the tauri-macros crate generates a wrapper function prefixed with __cmd__. According to the source code in crates/tauri-macros/src/command/handler.rs (lines 32-69), the Handler::parse method processes command definitions, formats wrapper identifiers using format_command_wrapper, and builds a dispatch map. The generated wrapper handles JSON deserialization of arguments and serialization of return values.

The generate_handler! Dispatcher

The tauri::generate_handler! macro expands into a match-statement that dispatches incoming IPC messages to the appropriate wrapper. In examples/commands/main.rs (lines 26-33), the macro collects command paths and creates a closure that matches invoke.message.command() against registered command names. This dispatcher is plugged into the Tauri builder via .invoke_handler(...).

Frontend Invocation

The JavaScript frontend calls commands through window.__TAURI__.invoke() or the higher-level invoke function from @tauri-apps/api. The payload serializes to JSON and travels over Tauri's IPC channel to the Rust runtime, which routes it to the generated dispatcher.

Step-by-Step Implementation Guide

Follow these steps to expose Rust functions to your frontend.

1. Create a Basic Command

Define a Rust function in your src-tauri/src/main.rs or a dedicated module. Apply the #[command] attribute and choose a descriptive name.

use tauri::command;

#[command]
fn greet(name: String) -> String {
    format!("Hello, {name}!")
}

The function can accept any serializable type and return String, i32, custom structs, or Result<T, E>.

2. Register the Command Handler

In your main function, use tauri::generate_handler! to register the command with the Tauri builder.

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

This macro expansion, detailed in crates/tauri-macros/src/command/handler.rs, creates the dispatch logic that bridges IPC messages to your greet function.

3. Call from the Frontend

Install @tauri-apps/api in your frontend project, then import and call the command.

import { invoke } from "@tauri-apps/api";

async function sayHello() {
  const reply = await invoke("greet", { name: "World" });
  console.log(reply); // → "Hello, World!"
}

sayHello();

The first argument matches the Rust function name exactly. The second argument is an object containing the parameters defined in your Rust signature.

Advanced Command Patterns

The Tauri command system supports sophisticated patterns for production applications.

Asynchronous Commands

Mark functions as async to perform non-blocking I/O operations. The generated wrapper automatically handles the async runtime integration.

#[command]
async fn fetch_data(url: String) -> Result<String, String> {
    let response = reqwest::get(&url).await
        .map_err(|e| e.to_string())?;
    response.text().await
        .map_err(|e| e.to_string())
}

State Injection

Access shared application state using the State<'_, T> extractor. First, manage the state in your builder:

#[derive(Debug)]
pub struct AppState {
    counter: std::sync::atomic::AtomicU64,
}

fn main() {
    tauri::Builder::default()
        .manage(AppState { 
            counter: std::sync::atomic::AtomicU64::new(0) 
        })
        .invoke_handler(tauri::generate_handler![increment])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Then access it in your command:

use tauri::State;
use std::sync::atomic::Ordering;

#[command]
fn increment(state: State<'_, AppState>) -> u64 {
    state.counter.fetch_add(1, Ordering::Relaxed) + 1
}

As shown in examples/commands/main.rs (lines 66-86), stateful commands work with both sync and async functions.

Window Access

Access the current window instance to perform window-specific operations:

use tauri::Window;

#[command]
fn window_label(window: Window) {
    println!("Current window: {}", window.label());
}

This pattern appears in examples/commands/main.rs (lines 31-35), demonstrating how the framework injects the Window object automatically.

Error Handling

Return Result<T, E> where E implements std::error::Error to propagate errors to JavaScript as rejected Promises.

use thiserror::Error;

#[derive(Error, Debug)]
enum ValidationError {
    #[error("input cannot be empty")]
    EmptyInput,
    #[error("input too long")]
    TooLong,
}

#[command]
fn validate_input(data: String) -> Result<String, ValidationError> {
    if data.is_empty() {
        Err(ValidationError::EmptyInput)
    } else if data.len() > 100 {
        Err(ValidationError::TooLong)
    } else {
        Ok(data)
    }
}

When the error variant triggers, the JavaScript Promise rejects with the error message string.

Summary

  • Annotate Rust functions with #[command] to expose them to the frontend; the macro in crates/tauri-macros/src/command/mod.rs generates __cmd__ wrappers automatically.
  • Register commands using tauri::generate_handler! in your main function to build the dispatch table defined in crates/tauri-macros/src/command/handler.rs.
  • Call commands from JavaScript using invoke("command_name", { args }) from @tauri-apps/api.
  • Inject framework types like Window and State<'_, T> to access runtime resources without manual initialization.
  • Handle errors by returning Result<T, E>; Tauri serializes errors into JavaScript Promise rejections.

Frequently Asked Questions

How does Tauri serialize data between JavaScript and Rust?

Tauri uses JSON serialization for all IPC communication. The generated command wrappers in crates/tauri-macros/src/command/wrapper.rs automatically serialize JavaScript arguments into Rust types using serde, and serialize Rust return values back to JSON for the frontend. Complex types must implement Serialize and Deserialize from the serde crate.

Can I use custom types as command arguments?

Yes, provided they implement serde::Deserialize. Define your struct in Rust and mirror its structure in TypeScript for type safety.

#[derive(Deserialize)]
struct User {
    id: u64,
    name: String,
}

#[command]
fn create_user(user: User) -> String {
    format!("Created user {}", user.name)
}

What is the performance cost of Tauri commands?

Commands have minimal overhead—just JSON serialization and one IPC hop. The wrapper generation happens at compile time, so runtime performance matches native Rust function calls once the data crosses the boundary. For high-frequency operations, batch calls or use Tauri's event system instead of individual commands.

How do I protect commands from unauthorized access?

Tauri implements Access Control Lists (ACL) that filter unused commands based on the application manifest. As shown in crates/tauri-macros/src/command/handler.rs (line 44), the filter_unused_commands function removes commands not explicitly allowed in your tauri.conf.json. Additionally, validate all inputs and implement authentication checks inside your command functions before executing sensitive operations.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →