# Tauri tutorial: build a desktop app with web technologies

> Build a Tauri 2 desktop notes app with HTML, CSS, JavaScript, a Rust command, local file storage, permissions, development, and packaging.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-07-29 | Topics: [JavaScript](https://flaviocopes.com/tags/js/) | Canonical: https://flaviocopes.com/tauri-desktop-app-tutorial/

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:

```bash
rustc --version
cargo --version
```

## Create the project

Run:

```bash
npm create tauri-app@latest
```

Choose:

- JavaScript
- npm
- Vanilla

Name the project `tauri-notes`.

Then:

```bash
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:

```text
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:

```html
<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:

```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:

```bash
npm run tauri add fs
```

This adds the Rust plugin, JavaScript package, and setup needed by the application.

Use it in `src/main.js`:

```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:

```json
{
  "$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:

```bash
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:

```rust
#[tauri::command]
fn note_summary(note: String) -> String {
    let words = note.split_whitespace().count();
    format!("{} words", words)
}
```

Register the command in the builder:

```rust
#[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:

```js
import { invoke } from '@tauri-apps/api/core'
```

Update the status while typing:

```js
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`.

```rust
#[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:

```js
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:

```bash
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.
