Tauri tutorial: build a desktop app with web technologies
By Flavio Copes
Build a Tauri 2 desktop notes app with HTML, CSS, JavaScript, a Rust command, local file storage, permissions, development, and packaging.
Tauri lets us build a desktop application with a web frontend and a Rust backend.
The interface uses HTML, CSS, and JavaScript. Tauri displays it in the operating system’s webview instead of bundling a complete browser engine with every application.
Rust handles native work and exposes selected commands to the frontend.
In this tutorial we’ll build a small notes app with Tauri 2.
The app will:
- edit a note in a web interface
- save it to the application data directory
- load it when the app opens
- call a Rust command
- grant only the filesystem permissions it needs
Install the prerequisites
Tauri needs:
- Node.js
- Rust
- operating-system build tools and webview dependencies
Install the platform prerequisites from the Tauri documentation before continuing. On Linux, the exact system packages depend on the distribution. On Windows, Tauri uses WebView2. On macOS, install Xcode command-line tools.
Check Rust:
rustc --version
cargo --version
Create the project
Run:
npm create tauri-app@latest
Choose:
- JavaScript
- npm
- Vanilla
Name the project tauri-notes.
Then:
cd tauri-notes
npm install
npm run tauri dev
The first Rust build takes longer because Cargo compiles dependencies. Later builds are incremental.
Tauri opens a real desktop window containing the Vite frontend.
Understand the project
The important directories are:
src/ web frontend
src-tauri/
src/ Rust code
capabilities/ frontend permissions
Cargo.toml Rust dependencies
tauri.conf.json application configuration
The frontend should not receive unlimited access to the computer. It calls specific Tauri APIs and commands permitted by the Rust application and capability files.
This boundary is central to Tauri’s security model.
Build the notes interface
Replace the main frontend HTML with:
<main>
<h1>Notes</h1>
<label for="note">Your note</label>
<textarea id="note" rows="14"></textarea>
<div>
<span id="status" aria-live="polite"></span>
<button id="save" type="button">Save</button>
</div>
</main>
<script type="module" src="/src/main.js"></script>
Add simple CSS:
:root {
font-family: system-ui, sans-serif;
color-scheme: light dark;
}
body {
margin: 0;
}
main {
max-width: 720px;
margin: 0 auto;
padding: 32px;
}
textarea {
box-sizing: border-box;
width: 100%;
padding: 12px;
font: inherit;
}
main div {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
}
Add the filesystem plugin
Tauri keeps many native features in plugins.
Run from the project root:
npm run tauri add fs
This adds the Rust plugin, JavaScript package, and setup needed by the application.
Use it in src/main.js:
import {
BaseDirectory,
exists,
readTextFile,
writeTextFile,
} from '@tauri-apps/plugin-fs'
const noteElement = document.querySelector('#note')
const saveButton = document.querySelector('#save')
const statusElement = document.querySelector('#status')
const filename = 'note.txt'
async function loadNote() {
const found = await exists(filename, {
baseDir: BaseDirectory.AppData,
})
if (found) {
noteElement.value = await readTextFile(filename, {
baseDir: BaseDirectory.AppData,
})
}
}
async function saveNote() {
await writeTextFile(filename, noteElement.value, {
baseDir: BaseDirectory.AppData,
})
statusElement.textContent = 'Saved'
setTimeout(() => {
statusElement.textContent = ''
}, 1200)
}
saveButton.addEventListener('click', saveNote)
loadNote().catch((error) => {
statusElement.textContent = `Could not load note: ${error}`
})
BaseDirectory.AppData resolves to the correct application-specific directory on each operating system.
We do not concatenate a home directory path ourselves.
Grant the smallest filesystem permission
Installing a plugin does not mean every frontend window can call every plugin command.
Open src-tauri/capabilities/default.json.
The generated plugin setup may add broad default filesystem access. For this app, restrict it to the commands and scope needed for the app data directory:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Permissions for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"fs:default",
"fs:create-app-specific-dirs",
{
"identifier": "fs:allow-exists",
"allow": [
{ "path": "$APPDATA/note.txt" }
]
},
{
"identifier": "fs:allow-read-text-file",
"allow": [
{ "path": "$APPDATA/note.txt" }
]
},
{
"identifier": "fs:allow-write-text-file",
"allow": [
{ "path": "$APPDATA/note.txt" }
]
}
]
}
Permission identifiers can evolve with plugin releases. Use:
npm run tauri permission ls
to inspect the permissions available in the installed version.
Capabilities answer two questions:
- which windows or webviews receive access?
- which commands and filesystem paths can they use?
Avoid granting the whole home directory when the app needs one private file.
Call Rust from JavaScript
The filesystem plugin is enough for this app, but custom Rust commands are how we implement native application logic.
Open src-tauri/src/lib.rs and add:
#[tauri::command]
fn note_summary(note: String) -> String {
let words = note.split_whitespace().count();
format!("{} words", words)
}
Register the command in the builder:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.invoke_handler(tauri::generate_handler![note_summary])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Import invoke in the frontend:
import { invoke } from '@tauri-apps/api/core'
Update the status while typing:
noteElement.addEventListener('input', async () => {
statusElement.textContent = await invoke('note_summary', {
note: noteElement.value,
})
})
Tauri serializes the JavaScript arguments and passes them to the Rust function. Command argument keys use camelCase by default.
The command returns a promise in JavaScript.
Return errors instead of panicking
A command that can fail should return a Result.
#[tauri::command]
fn validate_note(note: String) -> Result<(), String> {
if note.len() > 100_000 {
return Err("The note is too large".into());
}
Ok(())
}
Handle it in JavaScript:
try {
await invoke('validate_note', {
note: noteElement.value,
})
} catch (error) {
statusElement.textContent = String(error)
}
Do not use unwrap() on user input or expected runtime failures. A Rust panic is not a friendly validation message.
Validate again in Rust even when the frontend has validation. Frontend code is not a security boundary.
Develop without exposing a web server
During development, Tauri starts the Vite server and points the desktop webview at it.
In a production build, the frontend assets are bundled with the app. The person using the application does not need Node.js or a local development server.
Keep frontend network access deliberate. A desktop app can still have web security problems:
- cross-site scripting can become access to permitted native commands
- remote content may not be trustworthy
- secrets embedded in frontend JavaScript can be extracted
Use a restrictive content security policy, keep dependencies current, and grant narrow capabilities.
Build the desktop application
Run:
npm run tauri build
Tauri creates the application and platform-specific installer or bundle under src-tauri/target/release/bundle.
Building is not the same as distributing.
For real distribution you also need:
- application icons and metadata
- code signing
- platform notarization where required
- an update strategy
- builds for each target operating system and architecture
Build Windows installers on Windows and macOS bundles on macOS unless the platform documentation explicitly supports your cross-compilation setup.
What belongs in Rust?
Do not move code to Rust only because Tauri includes Rust.
Keep presentation and ordinary UI state in the frontend.
Use Rust or official plugins for:
- filesystem and operating-system access
- expensive native computation
- secure storage boundaries
- native libraries
- commands that must validate access before doing privileged work
Every command is part of the application’s privileged interface. Keep that interface small.
We now have a desktop notes app with a web interface, application-scoped file storage, a typed call into Rust, explicit permissions, and a production build.
The key Tauri idea is not merely “web app in a desktop window.” It is a webview with a narrow, declared bridge to native capabilities.
Related posts about js: