Skip to content
FLAVIO COPES
flaviocopes.com

Tauri tutorial: build a desktop app with web technologies

By

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:

Install the prerequisites

Tauri needs:

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:

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:

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:

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:

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:

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: