Desktop apps with Tauri
Set up a Tauri 2 project, call Rust commands from Vue with invoke(), return errors as rejected promises, and ship a Vite or Nuxt front end as a small desktop app.
What you'll learn
When you finish, you can do these.- 1Set up a Tauri project and open it in a desktop window
- 2Write a Rust command and call it from Vue with invoke()
- 3Show Rust's error on the page when a command fails
- 4Ship a Nuxt app in Tauri and build the installers
The notes app this track builds, a small Vue app for writing notes, runs in the browser and on phones. On a computer, people want its own window, an icon in the dock or taskbar, and notes saved as files on disk. Electron, a popular tool for desktop apps, can do that, but it packs a whole Chromium browser and Node.js into every app.
Tauri uses the system's own web view, a browser window with no address bar, plus a small core written in Rust, so a minimal app can be under 600 KB. You need one idea from Mobile apps with Capacitor: calls from the page to native code, here Tauri's Rust core, cross a bridge and return as promises. The Code tab holds the desktop notes app, its page in src/App.vue and its Rust side in src-tauri/. By the end you can build it and ship it as an installer.
Set up a project
A Tauri 2 project is a Vue app plus a native program written in Rust, so your computer needs more than Node 22 or 24:
- Rust, installed with
rustup. - On Windows, the Microsoft C++ Build Tools and WebView2, the Windows web view, which Windows 10 and 11 already include.
- On macOS, Xcode, or only its Command Line Tools for a desktop app.
- On Linux, the
webkit2gtk-4.1development package and the usual build tools.
With those installed, npm create tauri-app@latest creates the project: pick Vue and TypeScript. After npm install, npm run tauri dev runs it. The first run compiles the Rust side for a few minutes; after that, a window opens with your Vue app, and edits to .vue files reload in place.
The project has two halves. src/ is a normal Vite and Vue app. src-tauri/ is the native side, with its own src/ folder:
tauri.conf.jsonnames the app, sets the build commands, declares the windows and configures the installers.src/lib.rsholds your Rust code andrun(), the function that builds and starts the app.src/main.rsstarts the desktop program and only callsnotes_lib::run(), therun()oflib.rs. Phones skip it and loadlib.rsas a library.capabilities/decides which of Tauri's features the page may use. The next lesson, Tauri plugins, mobile and updates, covers it.
Call Rust with commands
A command is a Rust function the page can call. Mark it with #[tauri::command], a line above the function, register it, and call it from Vue with invoke(), which returns a promise. The notes app's load_note command reads one note, saved as a file such as 1.md in the app data folder, the folder the system gives each app.
- Vueinvoke('load_note', { noteId })
- IPCJSON
- Rust#[tauri::command]
- Result
- Promise resolves or rejects
load_note, in src-tauri/src/lib.rs, finds that folder, adds the file name and reads the file:
#[tauri::command]
async fn load_note(app: tauri::AppHandle, note_id: u32) -> Result<String, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let path = dir.join(format!("{note_id}.md"));
std::fs::read_to_string(&path).map_err(|e| format!("Could not open note {note_id}: {e}"))
}
Four rules come out of this one function.
- Arguments travel as one JSON object, and Tauri expects its keys in camelCase. The Rust
note_idisnoteIdin JavaScript.appis not sent at all: Tauri fills in thisAppHandleitself. - A command fails by returning
Result, Rust's type for a success (Ok) or an error (Err).Okresolves the promise;Errrejects it with the error value, which must turn into JSON, somap_errturns errors into text. - A plain
fncommand runs on the main thread, where slow work freezes the window; anasync fnruns on a background task. Async commands cannot take borrowed values such as&str, so they take owned values:Stringor numbers. - Registering means putting every command into one
tauri::generate_handler![…]list, whichrun()passes to a singleinvoke_handlercall. The Code tab's list holdsload_noteandgreet, a command that says hello to a name.
On the Vue side, src/App.vue calls the command inside try, and catch receives the Err value:
try {
note.value = await invoke<string>('load_note', { noteId })
} catch (e) {
error.value = String(e)
}
With no 1.md on disk yet, clicking the Open note 1 button shows Rust's message on the page, starting with Could not open note 1.
Commands you register are allowed in every window by default: capability files limit only Tauri's own features and plugins.
During npm run tauri dev, the window loads the page from http://localhost:1420. Open that address in a browser tab and every invoke() fails with a TypeError: the bridge to Rust exists only in the Tauri window. isTauri(), from @tauri-apps/api/core, tells the two apart, so the page can explain itself, as src/App.vue does, or fall back to a web version instead of breaking.
Ship a Nuxt app in Tauri
Nuxt, the framework built on Vue, works in Tauri too, with three changes. It usually renders pages on a server, but Tauri runs no server, so the app must be client-only: built files that run in the web view. Set ssr: false in nuxt.config.ts, build with nuxt generate, and point the build block of src-tauri/tauri.conf.json at Nuxt's output:
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:3000",
"beforeBuildCommand": "npm run generate",
"frontendDist": "../.output/public"
}
frontendDist, the folder of built files, is relative to src-tauri/, hence the ../, and devUrl is Nuxt's dev server on port 3000. nuxt.config.ts in the Code tab also pins the port, because Tauri waits for devUrl to answer.
Once the app works, with Vite or Nuxt, npm run tauri build compiles it and writes installers for your current system to src-tauri/target/release/bundle/.
What goes wrong
Two common bugs break the link between invoke() and a command. The first sends arguments in snake_case, with underscores, the way Rust writes them.
await invoke('load_note', { note_id: 1 })
The promise rejects with a message that ends in command load_note missing required key noteId. Tauri converted the Rust name to camelCase and found no such key. Send the camelCase key:
await invoke('load_note', { noteId: 1 })
Tauri finds noteId, and the command runs.
The second bug compiles cleanly. The app already has greet, and adding load_note on a handler line of its own looks harmless:
.invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri::generate_handler![load_note])
Only the last invoke_handler call counts, so invoke('greet', { name }) now rejects with Command greet not found. Put every command in one list:
.invoke_handler(tauri::generate_handler![greet, load_note])
Both commands are found: type Leo and press Greet, and the page says Hello, Leo!.
What to remember
- Tauri runs your Vue build in the system's web view with a Rust core. It needs Rust plus WebView2 on Windows, Xcode tools on macOS or WebKitGTK on Linux.
- Mark a function with
#[tauri::command], list it in the onegenerate_handler!call, and call it from Vue withinvoke(). Argument keys are camelCase in JavaScript. - Return
Resultfrom a command:Errrejects the promise, andcatchcan show it on the page. Useasync fnwith owned arguments for slow work. - Guard
invoke()withisTauri()when the same page also runs in a browser tab. - A Nuxt app needs
ssr: false,nuxt generateandfrontendDist: '../.output/public'.npm run tauri buildwrites the installers.
In your project
Do these in a real project. Each one says how to confirm it worked.- Create a project with
npm create tauri-app@latest, choosing Vue and TypeScript, then runnpm installandnpm run tauri dev. A desktop window opens with the Vue page, and saving a change toApp.vueupdates it at once. - Copy
load_notefrom the Code tab (with itsuse tauri::Manager;line) intolib.rsand register it, then replace yourApp.vuewith the Code tab's. Click Open note 1: the page showsCould not open note 1. Create1.mdin the app data folder, named after theidentifierintauri.conf.json(for the Code tab's app on macOS,~/Library/Application Support/school.vuejs.notes), click again, and the file's text appears. - Run
npm run tauri buildand opensrc-tauri/target/release/bundle/. Compare the installer's size with an Electron app you have installed: the Tauri one is far smaller.