How to Register a New Tauri Command in lib.rs and Call It from the Next.js Frontend
To register a new Tauri command in Meetily, annotate a Rust function with #[tauri::command] in frontend/src-tauri/src/lib.rs, add it to the tauri::generate_handler! macro, and invoke it from the frontend using the invoke function from @tauri-apps/api/core.
Meetily bridges its Rust backend with the Next.js frontend using Tauri’s command system. This pattern allows you to expose native capabilities—such as file system access, audio recording, or custom business logic—to the React UI while maintaining type safety and async performance. Below is the complete workflow for registering a new command and consuming it from the frontend.
Step 1: Define the Tauri Command in Rust
Commands reside in frontend/src-tauri/src/lib.rs as asynchronous functions marked with the #[tauri::command] attribute.
Command Signature and Return Types
The standard pattern uses generic Runtime and returns Result<T, String> to handle errors gracefully:
// File: frontend/src-tauri/src/lib.rs
#[tauri::command]
pub async fn greet<R: Runtime>(name: String) -> Result<String, String> {
// Perform async work here: file I/O, database queries, etc.
Ok(format!("Hello, {}!", name))
}
R: Runtime– Enables access to Tauri’s runtime capabilities and ensures compatibility with multi-platform builds.pubvisibility – The function must be public so the handler can reference it during macro expansion.Result<String, String>– On success, theOkvariant serializes to the frontend; on failure, theErrstring propagates as a rejected JavaScript Promise.
Accessing the AppHandle
When you need to interact with Tauri’s API (e.g., opening dialogs, managing windows, or accessing app directories), accept AppHandle<R> as the first parameter:
#[tauri::command]
pub async fn open_models_folder<R: Runtime>(app: AppHandle<R>) -> Result<(), String> {
app.shell()
.open(app.path().app_data_dir().unwrap().join("models"))
.map_err(|e| e.to_string())
}
Step 2: Register the Command in the Tauri Builder
Locate the invoke_handler section in frontend/src-tauri/src/lib.rs (approximately lines 525–560). Add your command identifier to the tauri::generate_handler! macro:
// File: frontend/src-tauri/src/lib.rs
pub fn run() {
let mut builder = tauri::Builder::default();
builder
.plugin(tauri_plugin_notification::init())
// ... other plugins ...
.invoke_handler(tauri::generate_handler![
start_recording,
stop_recording,
// ... existing commands ...
greet, // <-- newly added command
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_app_handle, _event| { /* ... */ });
}
The macro generates the routing glue that maps frontend invoke calls to the corresponding Rust functions. Each identifier in the list must match the function name exactly.
Step 3: Invoke the Command from the Next.js Frontend
Import the invoke helper from @tauri-apps/api/core and call it with the command name and a payload object:
// Example: frontend/src/components/Example.tsx
import { invoke } from '@tauri-apps/api/core';
import { useState } from 'react';
export default function Example() {
const [message, setMessage] = useState<string>('');
async function sayHello() {
try {
// Generic <string> matches the Ok return type in Rust
const result = await invoke<string>('greet', { name: 'Alice' });
setMessage(result); // "Hello, Alice!"
} catch (error) {
console.error('Command failed:', error);
}
}
return (
<div>
<button onClick={sayHello}>Greet</button>
{message && <p>{message}</p>}
</div>
);
}
- Argument mapping – The payload object keys (
name) must match the Rust parameter names. - Type safety – Use the generic parameter (
invoke<string>) to align with the Rust return type. - Error handling – Rust
Errvariants reject the Promise, so wrap calls intry/catchblocks.
Organizing Commands in Separate Modules
For larger features, move commands to dedicated modules (e.g., src/api/commands.rs) and re-export them in lib.rs:
// File: frontend/src-tauri/src/api/commands.rs
#[tauri::command]
pub async fn get_app_version() -> Result<String, String> {
Ok(env!("CARGO_PKG_VERSION").to_string())
}
Then in lib.rs:
mod api; // Bring module into scope
pub use api::commands::*; // Re-export public symbols
// In the invoke_handler:
.invoke_handler(tauri::generate_handler![
// ... other commands ...
get_app_version,
])
This pattern keeps lib.rs clean and groups related functionality by domain (audio, analytics, file system, etc.).
Debugging and Error Handling
Rust side – Run the application with RUST_LOG=debug ./clean_run.sh to see log::info! output and trace command execution flow.
Frontend side – All commands return Promises. Always implement .catch() or try/catch blocks to handle Rust errors gracefully in the UI.
Command not found errors – If the frontend reports "command not found," verify:
- The function is marked
#[tauri::command] - The identifier is listed in
tauri::generate_handler! - The Tauri application has been rebuilt (
cargo build) to include the new handler
Summary
- Define – Create an
async fnwith#[tauri::command]infrontend/src-tauri/src/lib.rs, usingResult<T, String>for error propagation. - Register – Add the function name to the
tauri::generate_handler!list inside the builder configuration (around line 525 in Meetily’slib.rs). - Invoke – Call
invoke<T>('commandName', { args })from any Next.js component after importing from@tauri-apps/api/core. - Scale – Move complex commands to submodules (e.g.,
src/audio/recording_commands.rs) and re-export them to keep the codebase maintainable.
Frequently Asked Questions
Why must the Rust function be public (pub)?
The tauri::generate_handler! macro generates static references to your functions during compilation. If the command is private to its module, the macro cannot generate the necessary FFI bridges to expose it to the JavaScript runtime. Marking the function pub ensures visibility across the crate boundary.
Can I use synchronous functions instead of async?
Yes, Tauri supports synchronous commands, but async is recommended for Meetily to prevent blocking the main thread during I/O operations (file writes, network requests, or audio processing). If you omit async, simply remove the .await on the frontend; however, the return type should still be Result<T, String> for consistency.
How do I pass complex objects between the frontend and backend?
Serialize complex data using JSON-compatible structures. On the Rust side, derive serde::Deserialize for input arguments and serde::Serialize for return types. On the frontend, pass plain JavaScript objects—the invoke function handles serialization automatically. Ensure field names match exactly between TypeScript interfaces and Rust structs.
Where should I place command logic for a large codebase?
For production-scale code, avoid bloating lib.rs. Instead, create domain-specific modules (e.g., src/audio/commands.rs, src/fs/commands.rs) and import them into lib.rs with mod and pub use. This mirrors Meetily’s existing pattern where recording commands live in frontend/src-tauri/src/audio/recording_commands.rs and are re-exported for registration in the main handler.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →