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

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-analyzerextension)
Platform-Specific Requirements
macOS:
xcode-select --installWindows:
- 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-devWhat 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-tsWhen prompted, select:
- Package manager: npm
- UI template: React
- UI flavor: TypeScript
Navigate to your project and install dependencies:
cd notes-app
npm installProject 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 dialogThis 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(¬es_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(¬es_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(¬e).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/uuidNote 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 devThis 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 buildThis compiles the Rust backend in release mode and bundles your app:
| Platform | Output | Size |
|---|---|---|
| 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 updaterConfigure 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.keyTroubleshooting
Rust compilation errors
Make sure your Rust toolchain is up to date:
rustup updateBlank window on Linux
Install the correct WebKitGTK version:
sudo apt install libwebkit2gtk-4.1-devPermission 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-sqlplugin - 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.
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

Optimizing Cursor AI for React and Next.js Development
A comprehensive guide to configuring Cursor AI settings and features for maximum efficiency when working with React, Next.js, and Tailwind CSS.

MCP‑Governed Agentic Automation: How to Ship AI Agents Safely in 2026
A practical blueprint for building AI agents with MCP servers, governance, and workflow automation—plus a safe rollout path for production teams.
Accelerate Your App Success: Building and Running with Firebase
Discover how to rapidly build, optimize, and scale your apps using Firebase. Learn the key tools and features to accelerate your app’s success from development to deployment.