A browser tab cannot open a file on your disk by path, add an item to the system menu bar or ship as an installer. A desktop app can. Tauri lets you build one with the Vue code you already write: the interface runs in the operating system's own webview, and a small Rust program around it does the work a web page is not allowed to do.
You will build a word counter: it opens a native file dialog, sends the chosen path to a Rust function, shows the count and ends up as an installer. The versions are Tauri 2.10 (the tauri Rust package, or crate, is at 2.10.3, the CLI at 2.10.1), Vue 3.5 and Vite 8.
Install Rust and the system dependencies
Tauri compiles a Rust program, so you need the Rust toolchain. On macOS and Linux, install it with rustup, the official installer:
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
On Windows, run the rustup installer from rust-lang.org and keep the MSVC toolchain as the default. The tauri crate declares Rust 1.77.2 as its minimum, and rustup gives you the current stable release, which is newer.
Each operating system also needs a few native pieces, listed in the prerequisites guide:
- macOS: Xcode, or just its command line tools (
xcode-select --install) if you only build desktop apps. - Windows: the Microsoft C++ Build Tools with "Desktop development with C++" ticked, plus WebView2, which Windows 10 version 1803 and later already include.
- Debian or Ubuntu:
libwebkit2gtk-4.1-dev,build-essentialand a handful of libraries the guide names.
You also need Node.js, the LTS release.
Create the project
Run the official scaffolding tool and answer its prompts:
npm create tauri-app@latest
# Project name: word-counter
# Identifier: com.example.wordcounter
# Frontend language: TypeScript / JavaScript
# Package manager: npm
# UI template: Vue, flavour: TypeScript
cd word-counter
npm install
npm run tauri dev
The project has two halves:
src/is a normal Vite and Vue app, withmain.tsandApp.vue.src-tauri/is a Rust project.src/lib.rsholds your Rust code,tauri.conf.jsonholds the app's configuration, andcapabilities/decides what the webview may call.src/main.rsis the desktop entry point. It only callsrun()fromlib.rs, so leave it alone.
npm run tauri dev starts Vite on port 1420, opens a native window that loads it, and watches both halves: Vue changes hot reload as usual, and a change under src-tauri rebuilds the Rust side and restarts the app. The first run downloads and compiles every Rust dependency, which takes several minutes. Later runs only rebuild your own code.
The template pins Vite 6 and @vitejs/plugin-vue 5, which accepts only Vite 5 and 6. To move to Vite 8, update both with npm install -D vite@8 @vitejs/plugin-vue@6. Vite 8 needs Node 20.19 or a later 20.x release, or Node 22.12 or later.
Call Rust from Vue
A command is a Rust function the frontend can call. Replace the template's greet command with one that reads a file and counts its words:
#[tauri::command]
fn count_words(path: String) -> Result<usize, String> {
let text = std::fs::read_to_string(&path).map_err(|err| err.to_string())?;
Ok(text.split_whitespace().count())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![count_words])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The #[tauri::command] attribute makes the function callable, and generate_handler! registers it. Whatever a command returns is serialised to JSON, and an Err rejects the promise on the JavaScript side.
Commands written in lib.rs must not be marked pub. The generated glue code would then define the same name twice, and the build stops with error[E0255]. Commands in a separate module are the opposite case: they need pub.
On the Vue side, invoke from @tauri-apps/api/core calls the command by name. Wrap it in a typed function, so components never repeat the command's name or guess what it returns:
import { invoke } from '@tauri-apps/api/core'
export function countWords(path: string): Promise<number> {
return invoke<number>('count_words', { path })
}
Arguments travel as one object whose keys match the Rust parameter names, written in camelCase: a Rust file_path becomes filePath in JavaScript.
Open a native file dialog with a plugin
Tauri keeps its core small and puts features such as dialogs, the file system and notifications into official plugins. Add the dialog plugin with the CLI:
npm run tauri add dialog
That one command adds the tauri-plugin-dialog crate, installs @tauri-apps/plugin-dialog, inserts .plugin(tauri_plugin_dialog::init()) into lib.rs and grants the plugin's default permission. Now the component can ask the user for a file and pass the path to Rust:
<script setup lang="ts">
import { ref } from 'vue'
import { open } from '@tauri-apps/plugin-dialog'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { countWords } from './lib/words'
const fileName = ref('')
const words = ref<number | null>(null)
const error = ref('')
async function pickFile() {
error.value = ''
const path = await open({
multiple: false,
directory: false,
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
})
if (!path) return // the user closed the dialog
try {
words.value = await countWords(path)
fileName.value = path.split(/[\\/]/).pop() ?? path
await getCurrentWindow().setTitle(`Word counter: ${fileName.value}`)
} catch (err) {
error.value = String(err)
}
}
</script>
<template>
<main>
<button type="button" @click="pickFile">Choose a text file</button>
<p v-if="words !== null">{{ fileName }} has {{ words }} words.</p>
<p v-if="error" role="alert">{{ error }}</p>
</main>
</template>
open shows the operating system's own file picker. With multiple and directory both false, it resolves with one path as a string, or null when the user cancels. On desktop that is a normal file system path, which std::fs in the command reads directly. The split keeps only the file name for display, whether the system separates folders with / or \.
Run the app and pick a file: the count appears. The window title does not change, though, and the error paragraph says why.
Grant permissions with capabilities
Code in the webview reaches the system only through Tauri's IPC layer, the bridge that carries invoke calls. Plugin and core commands on that bridge run only when a capability grants them. Capabilities live as JSON files in src-tauri/capabilities; here is the template's file after the changes this app needs:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"dialog:default",
"core:window:allow-set-title"
]
}
A capability names the windows it applies to, here only main, and the permissions it grants:
core:defaultcovers the basics. Its window permissions mostly read state, such as the size or the title.opener:defaultcame with the template's opener plugin. It lets the app open web, mail and phone links in their default app and show a file in the file manager.dialog:defaultwas added bytauri addand allows the ask, confirm, message, open and save dialogs.core:window:allow-set-titleis the line this app adds. Changing the title is not part ofcore:default, sosetTitlewas rejected, and in a dev build the error names this permission. Add the line and the title updates.
Commands you register yourself with invoke_handler are allowed in every window by default. That makes count_words your responsibility: it reads any path the frontend sends, so in a real app check the path before you trust it.
Set the window size and title
The starting window is configured in tauri.conf.json, under app.windows. The template opens an 800 by 600 window titled with the project name. A word counter needs less room:
{
"app": {
"windows": [
{
"title": "Word counter",
"width": 480,
"height": 360,
"minWidth": 360,
"minHeight": 240
}
]
}
}
That shows only the app.windows part; keep the rest of the generated file. A window without a label is called main, which is the name the capability above refers to. If you add a second window, give it a label and decide which capability, if any, should include it.
Build an installer
One command builds the Vue app, compiles Rust in release mode and packages the result:
npm run tauri build
The installers land in src-tauri/target/release/bundle/, one folder per format, such as a .dmg on macOS or an .msi and a setup .exe on Windows.
Tauri's docs say a minimal app can be less than 600 KB, because it uses the webview already on the user's machine, while every Electron app ships its own copy of Chromium and Node.js. Your Vue bundle and Rust code add to that figure, but the browser engine never does.
Build each installer on the system it is for, on your own machine or in a CI job such as the official tauri-action for GitHub Actions; the docs treat cross-compiling Windows installers from Linux or macOS as a last resort. Most platforms also expect the app to be code signed before users can open it without a warning.