Tauri 2 + React: Build a Cross-Platform Desktop App with Rust

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Introduction

Electron has long been the default for building desktop apps with web technologies, but it ships an entire Chromium browser with every app — resulting in 150MB+ binaries and high memory usage. Tauri 2 changes the game by using the operating system's native webview, producing apps that are 10-100x smaller and significantly faster.

With Tauri 2, you write your UI in React (or any frontend framework) and your backend logic in Rust. The result is a secure, performant desktop app that runs on Windows, macOS, Linux — and even iOS and Android.

In this tutorial, you'll build a Notes Desktop App — a simple but functional note-taking application with file system access, a system tray icon, and cross-platform packaging.

Prerequisites

Before starting, ensure you have:

  • Node.js 18+ installed
  • Rust installed via rustup
  • Basic knowledge of React and TypeScript
  • A code editor (VS Code recommended with the rust-analyzer extension)

Platform-Specific Requirements

macOS:

xcode-select --install

Windows:

  • Microsoft Visual Studio C++ Build Tools
  • WebView2 (pre-installed on Windows 10/11)

Linux (Ubuntu/Debian):

sudo apt update
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev

What You'll Build

A Notes Desktop App with the following features:

  • Create, edit, and delete notes
  • Save notes to the local file system
  • System tray integration with quick actions
  • Cross-platform packaging (Windows, macOS, Linux)
  • Native window controls and menus

Step 1: Create the Tauri Project

Use the official create-tauri-app scaffolding tool to bootstrap your project:

npm create tauri-app@latest notes-app -- --template react-ts

When prompted, select:

  • Package manager: npm
  • UI template: React
  • UI flavor: TypeScript

Navigate to your project and install dependencies:

cd notes-app
npm install

Project Structure

Your project has two main parts:

notes-app/
├── src/              # React frontend
│   ├── App.tsx
│   ├── main.tsx
│   └── styles.css
├── src-tauri/        # Rust backend
│   ├── src/
│   │   └── lib.rs
│   ├── Cargo.toml
│   ├── tauri.conf.json
│   └── capabilities/
└── package.json

The src/ directory contains your React app, while src-tauri/ contains the Rust backend and Tauri configuration.

Step 2: Configure Tauri

Open src-tauri/tauri.conf.json and update the app configuration:

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "Notes App",
  "version": "1.0.0",
  "identifier": "com.notes.app",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:1420",
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run build"
  },
  "app": {
    "title": "Notes App",
    "windows": [
      {
        "title": "Notes App",
        "width": 900,
        "height": 680,
        "resizable": true,
        "fullscreen": false
      }
    ],
    "security": {
      "csp": null
    }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ]
  }
}

Step 3: Add Tauri Plugins

Tauri 2 uses a plugin system for accessing system APIs. Install the file system and dialog plugins:

npm run tauri add fs
npm run tauri add dialog

This updates both the package.json (JavaScript bindings) and Cargo.toml (Rust dependencies).

Configure Permissions

Tauri 2 requires explicit permissions for plugin access. Update src-tauri/capabilities/default.json:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "fs:allow-read-text-file",
    "fs:allow-write-text-file",
    "fs:allow-exists",
    "fs:allow-mkdir",
    "fs:allow-read-dir",
    "fs:allow-remove",
    "fs:scope-app-data",
    "dialog:allow-open",
    "dialog:allow-save",
    "dialog:allow-message"
  ]
}

Step 4: Create Rust Backend Commands

Tauri commands are Rust functions that your frontend can call. Open src-tauri/src/lib.rs and add the note management logic:

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
 
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Note {
    pub id: String,
    pub title: String,
    pub content: String,
    pub created_at: String,
    pub updated_at: String,
}
 
fn get_notes_dir(app: &tauri::AppHandle) -> PathBuf {
    let data_dir = app.path().app_data_dir().expect("Failed to get app data dir");
    let notes_dir = data_dir.join("notes");
    if !notes_dir.exists() {
        fs::create_dir_all(&notes_dir).expect("Failed to create notes directory");
    }
    notes_dir
}
 
#[tauri::command]
fn list_notes(app: tauri::AppHandle) -> Result<Vec<Note>, String> {
    let notes_dir = get_notes_dir(&app);
    let mut notes: Vec<Note> = Vec::new();
 
    let entries = fs::read_dir(&notes_dir).map_err(|e| e.to_string())?;
 
    for entry in entries {
        let entry = entry.map_err(|e| e.to_string())?;
        let path = entry.path();
        if path.extension().map_or(false, |ext| ext == "json") {
            let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
            let note: Note = serde_json::from_str(&content).map_err(|e| e.to_string())?;
            notes.push(note);
        }
    }
 
    notes.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
    Ok(notes)
}
 
#[tauri::command]
fn save_note(app: tauri::AppHandle, note: Note) -> Result<Note, String> {
    let notes_dir = get_notes_dir(&app);
    let file_path = notes_dir.join(format!("{}.json", note.id));
    let json = serde_json::to_string_pretty(&note).map_err(|e| e.to_string())?;
    fs::write(&file_path, json).map_err(|e| e.to_string())?;
    Ok(note)
}
 
#[tauri::command]
fn delete_note(app: tauri::AppHandle, id: String) -> Result<(), String> {
    let notes_dir = get_notes_dir(&app);
    let file_path = notes_dir.join(format!("{}.json", id));
    if file_path.exists() {
        fs::remove_file(&file_path).map_err(|e| e.to_string())?;
    }
    Ok(())
}
 
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_dialog::init())
        .invoke_handler(tauri::generate_handler![list_notes, save_note, delete_note])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Add the serde_json dependency to src-tauri/Cargo.toml:

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"

Step 5: Build the React Frontend

Now let's create the UI. First, install a few helpful packages:

npm install uuid
npm install -D @types/uuid

Note Type Definition

Create src/types.ts:

export interface Note {
  id: string;
  title: string;
  content: string;
  created_at: string;
  updated_at: string;
}

Notes List Component

Create src/components/NotesList.tsx:

import { Note } from "../types";
 
interface NotesListProps {
  notes: Note[];
  selectedId: string | null;
  onSelect: (note: Note) => void;
  onDelete: (id: string) => void;
  onCreate: () => void;
}
 
export function NotesList({ notes, selectedId, onSelect, onDelete, onCreate }: NotesListProps) {
  return (
    <aside className="notes-sidebar">
      <div className="sidebar-header">
        <h2>Notes</h2>
        <button onClick={onCreate} className="btn-new" title="New Note">
          +
        </button>
      </div>
      <ul className="notes-list">
        {notes.map((note) => (
          <li
            key={note.id}
            className={`note-item ${selectedId === note.id ? "active" : ""}`}
            onClick={() => onSelect(note)}
          >
            <div className="note-item-title">
              {note.title || "Untitled"}
            </div>
            <div className="note-item-date">
              {new Date(note.updated_at).toLocaleDateString()}
            </div>
            <button
              className="btn-delete"
              onClick={(e) => {
                e.stopPropagation();
                onDelete(note.id);
              }}
              title="Delete"
            >
              ×
            </button>
          </li>
        ))}
      </ul>
    </aside>
  );
}

Note Editor Component

Create src/components/NoteEditor.tsx:

import { useState, useEffect } from "react";
import { Note } from "../types";
 
interface NoteEditorProps {
  note: Note | null;
  onSave: (note: Note) => void;
}
 
export function NoteEditor({ note, onSave }: NoteEditorProps) {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
 
  useEffect(() => {
    if (note) {
      setTitle(note.title);
      setContent(note.content);
    }
  }, [note?.id]);
 
  if (!note) {
    return (
      <main className="editor-empty">
        <p>Select a note or create a new one</p>
      </main>
    );
  }
 
  const handleSave = () => {
    onSave({
      ...note,
      title,
      content,
      updated_at: new Date().toISOString(),
    });
  };
 
  return (
    <main className="editor">
      <input
        type="text"
        className="editor-title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        onBlur={handleSave}
        placeholder="Note title..."
      />
      <textarea
        className="editor-content"
        value={content}
        onChange={(e) => setContent(e.target.value)}
        onBlur={handleSave}
        placeholder="Start writing..."
      />
    </main>
  );
}

Main App Component

Replace src/App.tsx:

import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { v4 as uuidv4 } from "uuid";
import { NotesList } from "./components/NotesList";
import { NoteEditor } from "./components/NoteEditor";
import { Note } from "./types";
import "./App.css";
 
function App() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [selectedNote, setSelectedNote] = useState<Note | null>(null);
 
  useEffect(() => {
    loadNotes();
  }, []);
 
  async function loadNotes() {
    try {
      const result = await invoke<Note[]>("list_notes");
      setNotes(result);
    } catch (error) {
      console.error("Failed to load notes:", error);
    }
  }
 
  async function createNote() {
    const newNote: Note = {
      id: uuidv4(),
      title: "",
      content: "",
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    };
 
    try {
      await invoke("save_note", { note: newNote });
      setNotes((prev) => [newNote, ...prev]);
      setSelectedNote(newNote);
    } catch (error) {
      console.error("Failed to create note:", error);
    }
  }
 
  async function saveNote(note: Note) {
    try {
      await invoke("save_note", { note });
      setNotes((prev) =>
        prev.map((n) => (n.id === note.id ? note : n))
      );
      setSelectedNote(note);
    } catch (error) {
      console.error("Failed to save note:", error);
    }
  }
 
  async function deleteNote(id: string) {
    try {
      await invoke("delete_note", { id });
      setNotes((prev) => prev.filter((n) => n.id !== id));
      if (selectedNote?.id === id) {
        setSelectedNote(null);
      }
    } catch (error) {
      console.error("Failed to delete note:", error);
    }
  }
 
  return (
    <div className="app">
      <NotesList
        notes={notes}
        selectedId={selectedNote?.id ?? null}
        onSelect={setSelectedNote}
        onDelete={deleteNote}
        onCreate={createNote}
      />
      <NoteEditor note={selectedNote} onSave={saveNote} />
    </div>
  );
}
 
export default App;

Styling

Replace src/App.css with a clean, native-looking design:

:root {
  --sidebar-width: 280px;
  --bg-primary: #1e1e2e;
  --bg-secondary: #181825;
  --bg-surface: #313244;
  --text-primary: #cdd6f4;
  --text-secondary: #a6adc8;
  --accent: #89b4fa;
  --danger: #f38ba8;
  --border: #45475a;
}
 
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
 
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: var(--bg-primary);
  color: var(--text-primary);
}
 
.app {
  display: flex;
  height: 100vh;
}
 
/* Sidebar */
.notes-sidebar {
  width: var(--sidebar-width);
  background: var(--bg-secondary);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
}
 
.sidebar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  border-bottom: 1px solid var(--border);
}
 
.sidebar-header h2 {
  font-size: 18px;
  font-weight: 600;
}
 
.btn-new {
  width: 32px;
  height: 32px;
  border-radius: 8px;
  border: none;
  background: var(--accent);
  color: var(--bg-primary);
  font-size: 20px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
 
.btn-new:hover {
  opacity: 0.85;
}
 
/* Notes List */
.notes-list {
  list-style: none;
  overflow-y: auto;
  flex: 1;
}
 
.note-item {
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  position: relative;
  transition: background 0.15s;
}
 
.note-item:hover {
  background: var(--bg-surface);
}
 
.note-item.active {
  background: var(--bg-surface);
  border-left: 3px solid var(--accent);
}
 
.note-item-title {
  font-size: 14px;
  font-weight: 500;
  margin-bottom: 4px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
 
.note-item-date {
  font-size: 11px;
  color: var(--text-secondary);
}
 
.btn-delete {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 24px;
  height: 24px;
  border-radius: 4px;
  border: none;
  background: transparent;
  color: var(--text-secondary);
  font-size: 16px;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s;
}
 
.note-item:hover .btn-delete {
  opacity: 1;
}
 
.btn-delete:hover {
  background: var(--danger);
  color: white;
}
 
/* Editor */
.editor {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: 24px;
}
 
.editor-empty {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-secondary);
}
 
.editor-title {
  font-size: 28px;
  font-weight: 700;
  border: none;
  background: transparent;
  color: var(--text-primary);
  outline: none;
  margin-bottom: 16px;
  padding: 4px 0;
}
 
.editor-content {
  flex: 1;
  border: none;
  background: transparent;
  color: var(--text-primary);
  outline: none;
  font-size: 15px;
  line-height: 1.7;
  resize: none;
  font-family: inherit;
}

Step 6: Run the Development Server

Start the Tauri development server:

npm run tauri dev

This launches both the Vite dev server (for React hot-reload) and the Tauri window. You should see your Notes app with a sidebar and editor. Rust recompiles automatically when you change src-tauri/ files.

Step 7: Add a System Tray

Tauri 2 supports system tray integration natively. Update src-tauri/src/lib.rs to add a tray icon:

use tauri::{
    menu::{Menu, MenuItem},
    tray::TrayIconBuilder,
    Manager,
};
 
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_dialog::init())
        .setup(|app| {
            let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
            let show = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?;
            let menu = Menu::with_items(app, &[&show, &quit])?;
 
            TrayIconBuilder::new()
                .menu(&menu)
                .tooltip("Notes App")
                .on_menu_event(|app, event| match event.id.as_ref() {
                    "quit" => app.exit(0),
                    "show" => {
                        if let Some(window) = app.get_webview_window("main") {
                            let _ = window.show();
                            let _ = window.set_focus();
                        }
                    }
                    _ => {}
                })
                .build(app)?;
 
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![list_notes, save_note, delete_note])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Add a tray icon image at src-tauri/icons/tray.png (32x32 PNG with transparency).

Step 8: Build and Package

When your app is ready, build it for distribution:

npm run tauri build

This compiles the Rust backend in release mode and bundles your app:

PlatformOutputSize
macOS.dmg, .app~8 MB
Windows.msi, .exe~5 MB
Linux.deb, .AppImage~7 MB

Bundles are output to src-tauri/target/release/bundle/.

Compare that to an equivalent Electron app at 150-200 MB — Tauri apps are dramatically smaller.

Step 9: Auto-Updater (Optional)

Tauri 2 includes a built-in updater. Add it to your project:

npm run tauri add updater

Configure the update endpoint in tauri.conf.json:

{
  "plugins": {
    "updater": {
      "endpoints": [
        "https://releases.myapp.com/{{target}}/{{arch}}/{{current_version}}"
      ],
      "pubkey": "YOUR_PUBLIC_KEY"
    }
  }
}

Generate signing keys with:

npm run tauri signer generate -- -w ~/.tauri/myapp.key

Troubleshooting

Rust compilation errors

Make sure your Rust toolchain is up to date:

rustup update

Blank window on Linux

Install the correct WebKitGTK version:

sudo apt install libwebkit2gtk-4.1-dev

Permission denied errors

Check that your capabilities/default.json includes the required permissions for each plugin you're using. Tauri 2 is strict about permissions by design.

Slow first build

The first Rust compilation downloads and compiles all dependencies. Subsequent builds are much faster due to caching. Use cargo clean only when necessary.

Next Steps

Now that you have a working Tauri 2 desktop app, you can:

  • Add keyboard shortcuts with Tauri's global shortcut plugin
  • Implement Markdown rendering in the editor
  • Add search functionality across notes
  • Set up CI/CD with GitHub Actions for automated builds
  • Add database storage with SQLite via the tauri-plugin-sql plugin
  • Build for mobile (iOS/Android) using Tauri 2's mobile support

Conclusion

You've built a complete desktop application using Tauri 2 and React. Compared to Electron, your app is smaller, faster, and more secure — leveraging Rust's performance and Tauri's permission-based security model. The plugin system makes it easy to access native APIs while maintaining a small footprint. Tauri 2 represents the future of cross-platform desktop development for web developers.


Want to read more tutorials? Check out our latest tutorial on Mastering the Salla App Store: Essential Publishing Standards for Developers.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles