src-lib.rs - The Rust library entry point for Tauri apps
Understand why Tauri uses a library crate entry point src-lib.rs how it enables desktop and mobile builds and how to register commands plugins and setup logic there
Every Tauri project contains a Rust crate inside the src-tauri directory. Among the files that ship with the scaffolded project, src/lib.rs is the single most important Rust file you will touch during development. It holds the definition of your app's run function, acts as the registration point for commands and plugins, and serves as the entry point for both desktop and mobile platforms through a carefully designed pattern.
What src/lib.rs Is and Why It Exists
Tauri splits the Rust side of your application into two crates that live in the same Cargo.toml package: a binary crate (the file src/main.rs) and a library crate (the file src/lib.rs). This split is not an accident — it is driven by the fact that Tauri supports macOS, Windows, Linux, iOS, and Android from a single codebase.
Desktop builds need a standalone binary. The operating system launches an executable, and that executable must call into the Tauri runtime, create windows, and start the event loop. Mobile builds work differently. On iOS and Android, your Rust code compiles into a shared library that the native platform shells load at runtime. A library exposes a function the system calls to start your application; it does not have a main function.
By placing the core startup logic in a library crate, Tauri compiles the exact same run() function for both targets. The binary crate main.rs calls that library function on desktop. The mobile platform directly invokes the library entry point generated by the #[cfg_attr(mobile, tauri::mobile_entry_point)] attribute. You write the application setup once, in lib.rs, and it works everywhere.
Think of it as the brain of the application:
All logic that sets up the Tauri app — registering commands, attaching plugins, managing state — belongs in lib.rs. The main.rs file should be left untouched unless you have a very specific desktop-only need.
The Default lib.rs in a Fresh Tauri Project
When you scaffold a new Tauri v2 project with create-tauri-app, the generated src-tauri/src/lib.rs looks like this:
// src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
It is intentionally minimal. The function run is public so that main.rs can call it as app_lib::run(). The attribute on top is conditional: on mobile targets, it expands to extra code that exposes the correct entry point for the operating system. On desktop builds, it expands to nothing.
The body creates a default tauri::Builder, calls run, and passes the generated context that embeds your configuration and static web assets. There are no commands, no plugins, no state — yet. Most real-world applications will grow this function considerably.
How the Library Crate Pattern Works in Tauri v2
Behind the scenes, the Cargo.toml file inside src-tauri declares a library target alongside the binary target:
# src-tauri/Cargo.toml (relevant snippet)
[package]
name = "app"
version = "0.1.0"
edition = "2021"
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "app"
path = "src/main.rs"
The [lib] section defines the library crate with the name app_lib. The crate-type array tells the compiler to produce multiple output formats: staticlib and cdylib for mobile targets, and rlib for Rust internal linking during desktop builds. The binary target uses src/main.rs and produces the final executable on desktop.
Inside src/main.rs, the code is deliberately trivial:
// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
The binary’s only job is to call the library’s run function. This indirection is what makes Tauri cross-platform without conditional compilation at every call site. Mobile builds never compile main.rs; they only compile the library. Desktop builds compile both, but the library contains all the application logic.
The run Function and the mobile_entry_point Attribute
The run function is where you build and launch your Tauri application. The default version only calls .run(tauri::generate_context!()), which is enough to display your web frontend. Any customisation — commands, plugins, state, setup hooks — happens on the builder before run is called.
The #[cfg_attr(mobile, tauri::mobile_entry_point)] attribute deserves close attention. On mobile, it generates a function that the platform-specific wrapper (written in Swift for iOS or Kotlin/Java for Android) can call. Without this attribute, the Tauri build for mobile will fail with a linker error because the required symbol is missing.
Missing the attribute breaks mobile builds completely:
If you accidentally delete or comment out #[cfg_attr(mobile, tauri::mobile_entry_point)] from the run function, your project will compile for desktop but fail to link for iOS or Android. Always keep it exactly as generated.
The mobile cfg flag is set automatically by Tauri’s build system when compiling for mobile targets. You do not need to define it yourself, and you should not wrap other code with it unless you are writing mobile-only logic.
Registering Commands in src/lib.rs
Commands are the bridge between your frontend JavaScript and your Rust backend. You define them with #[tauri::command] and register them on the builder using .invoke_handler. While the command functions themselves can live in separate modules or files, the registration step happens in lib.rs.
Imagine you have a commands module that exports a command to greet the user:
// src-tauri/src/commands.rs
#[tauri::command]
pub fn greet(name: &str) -> String {
format!("Hello, {}! You are running a Tauri app.", name)
}
In lib.rs, you import the command and register it:
// src-tauri/src/lib.rs
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![commands::greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The generate_handler! macro takes a list of command function paths and wires them into Tauri’s IPC dispatch system. You can register as many commands as you need, separated by commas. The order does not matter.
A command must be registered to be callable:
If you write a #[tauri::command] function but forget to list it in .invoke_handler, the frontend will receive an error when it tries to call invoke. Tauri’s capability system also requires the command to be allowed in a capability file — both steps are necessary.
For larger projects, it is common to define commands across multiple modules and collect them into a single registration call:
use commands::user::*;
use commands::settings::*;
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
create_user,
delete_user,
get_settings,
update_settings,
])
Adding Plugins
Tauri plugins provide access to native platform features like the file system, notifications, and shell commands. Each plugin must be registered on the builder in lib.rs using the .plugin() method. The exact initialisation function varies per plugin, but the pattern is uniform.
For example, to add the file system plugin:
First, add the crate to Cargo.toml:
[dependencies]
tauri-plugin-fs = "2"
Then register it in lib.rs:
// src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Each plugin’s init() function returns an object that implements tauri::plugin::Plugin. You can chain multiple .plugin() calls to add several plugins. The order does not matter, but placing them before run keeps the builder readable.
Plugins often need both a Rust registration and a frontend npm package:
Adding tauri_plugin_fs::init() only enables the Rust side. You must also install the matching JavaScript package (like @tauri-apps/plugin-fs) and, in many cases, grant permissions in a capability file. Refer to each plugin’s documentation for the full setup.
Relationship with src/main.rs and Cargo.toml
The division of labour between these three files is fixed by Tauri’s project template and should be preserved:
Cargo.tomldefines the library name (defaultapp_lib) and the binary target. Changing the library name means you must update both theusestatement inmain.rsand the[lib]section. Avoid doing this unless you have a strong reason.src/main.rscallsapp_lib::run()and does nothing else. It exists only to give desktop builds amainfunction.src/lib.rscontains therunfunction and all builder customisation. This is the file you modify for the entire lifetime of the project.
This separation works automatically:
If you follow the template and put all your setup in lib.rs, both tauri dev and tauri build will compile correctly for desktop and mobile without any additional configuration. The build system knows when to use the binary and when to use the library.
Common Mistakes
A handful of errors recur frequently among developers new to Tauri v2.
Editing main.rs instead of lib.rs.
If you add commands, plugins, or state management to main.rs, they will not be present in mobile builds because main.rs is never compiled on those targets. Your app will work on desktop but fail silently or panic on Android and iOS.
Never put application setup in main.rs:
The one and only purpose of main.rs is to call app_lib::run(). Move all builder configuration into lib.rs to keep mobile builds consistent.
Forgetting the mobile_entry_point attribute.
Removing or breaking the #[cfg_attr(mobile, tauri::mobile_entry_point)] line causes a linker error on mobile builds. The error message may mention an undefined symbol, but the root cause is always the missing attribute.
Mismatched library and binary names.
If you rename the library in Cargo.toml without updating main.rs, the binary will fail to compile because the app_lib module cannot be found. Keep the defaults unless you are deliberately restructuring the crate.
Calling run before all builder methods.
The builder pattern expects you to chain method calls and end with run. If you call another method after run, it will not be executed because run starts the event loop. Always make run the final call in the chain.
A Complete Example: lib.rs with Commands, State, and Plugins
The following example brings together command registration, plugin integration, and application state — a realistic starting point for a Tauri v2 app:
// src-tauri/src/lib.rs
mod commands;
use std::sync::Mutex;
// Shared application state
pub struct AppState {
pub counter: Mutex<i64>,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let state = AppState {
counter: Mutex::new(0),
};
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.manage(state)
.invoke_handler(tauri::generate_handler![
commands::greet,
commands::increment_counter,
commands::get_counter,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
A companion commands.rs might look like this:
// src-tauri/src/commands.rs
use tauri::State;
use crate::AppState;
#[tauri::command]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[tauri::command]
pub fn increment_counter(state: State<'_, AppState>) -> Result<i64, String> {
let mut counter = state.counter.lock().map_err(|e| e.to_string())?;
*counter += 1;
Ok(*counter)
}
#[tauri::command]
pub fn get_counter(state: State<'_, AppState>) -> Result<i64, String> {
let counter = state.counter.lock().map_err(|e| e.to_string())?;
Ok(*counter)
}
State managed with .manage() is injected automatically into command parameters annotated with State<T>. The Mutex is necessary because Tauri commands can execute concurrently on the async runtime.
This pattern scales well: new capabilities become new entries in invoke_handler and new modules, while the structure of lib.rs remains clean.
Summary
src/lib.rs is not just another Rust file — it is the centrepiece of your Tauri application. It houses the run function that boots the app, and it is the single place where commands, plugins, state, and system hooks are wired together. The separation between a minimal main.rs for desktop and a fully featured library for all targets is what makes Tauri v2 truly cross-platform.
When you start a new feature, the first question should be: "Does this go in lib.rs, or in a module that lib.rs imports?" The answer is almost always the latter — lib.rs orchestrates; everything else provides the pieces. Keep main.rs untouched, always include the mobile_entry_point attribute, and register every command and plugin you intend to use.