Use Cases (Updated: 6/2/2026)

Tauri Desktop App Development with Claude Code: Rust Commands, Vite, and Permissions

Build a Tauri v2 desktop app with Claude Code, Vite, Rust commands, invoke, permissions, tests, and review prompts.

Tauri Desktop App Development with Claude Code: Rust Commands, Vite, and Permissions

Tauri turns a web UI into a desktop app for Windows, macOS, and Linux. The UI can be React, Svelte, Vue, or another frontend stack, while the native side is written in Rust. That split is the reason Tauri works well with Claude Code: you can ask for one UI change, one Rust command, one capability rule, or one build fix at a time and still keep the diff reviewable.

This guide uses Tauri v2 and a small local notes app as the running example. It covers when to choose Tauri, setup with Vite + React or Svelte, Rust commands, frontend invoke calls, file system permissions, build and test commands, permission-review prompts, real use cases, and the traps that usually make a desktop app unsafe.

The official references used for this update are Tauri Create a Project, Calling Rust from the Frontend, Tauri Capabilities, Tauri File System plugin, Vite Getting Started, Cargo test, and Claude Code setup.

If you want the Rust foundation first, read Rust development with Claude Code. If you are deciding between Electron and Tauri, compare this with Electron desktop app development.

When Tauri Fits

Choose Tauri when the app needs a local desktop shape and real access to local resources. Good examples are a private notes tool, a CSV converter for internal files, a log viewer, a small offline-first field app, or a developer utility that should feel native but still benefit from web UI speed.

Do not choose Tauri only because it sounds lighter than Electron. Tauri adds real responsibilities: Rust toolchains, code signing, installers, OS-specific behavior, app identifiers, update strategy, and native permissions. If the product is basically a hosted web app with login, server APIs, and no local file access, a normal web app or PWA may be simpler.

The safest way to start with Claude Code is to make the boundaries explicit before implementation:

We are building a Tauri v2 local notes app.
Do not implement yet.
Split the work into React UI, Rust commands, capabilities, and build/test checks.
File access must stay inside the app data directory.
Do not propose arbitrary path reads or broad file-system permissions.

That one prompt prevents a common failure: letting the agent invent broad permissions before the product has a real security model.

Architecture Boundary

The frontend should not touch the operating system directly. It calls named Rust commands through Tauri’s invoke API. Rust validates the request, touches the local resource, and returns a typed result.

flowchart LR
  UI["React or Svelte UI"] --> Invoke["invoke from @tauri-apps/api/core"]
  Invoke --> Command["Rust command"]
  Command --> Guard["path allowlist and validation"]
  Guard --> AppData["app data directory"]
  Command --> Result["typed result"]
  Result --> UI
  Capability["Tauri capability"] --> UI

There is one security detail beginners often miss: Tauri capabilities constrain frontend access to Tauri APIs and plugins, but they do not automatically make your custom Rust commands safe. If your Rust command accepts an arbitrary path and writes to it, the app process can still do dangerous work. Capabilities and Rust-side validation must both be reviewed.

Setup with React or Svelte

For a new project, the official create-tauri-app path is the cleanest starting point. Pick React or Svelte, then pick TypeScript so the frontend boundary stays typed.

npm create tauri-app@latest taskdesk
cd taskdesk
npm install
npm run tauri dev

For an existing Vite app, scaffold the frontend first and then initialize Tauri. Vite’s current guide documents npm create vite@latest and notes that Vite requires Node.js 20.19+ or 22.12+, so check Node before chasing Tauri errors.

node --version
npm create vite@latest taskdesk -- --template react-ts
cd taskdesk
npm install
npm install -D @tauri-apps/cli@latest
npm install @tauri-apps/api@latest
npx tauri init
npx tauri dev

For Svelte, change only the Vite template:

npm create vite@latest taskdesk -- --template svelte-ts

After setup, ask Claude Code to read the generated files before editing them:

Read package.json, tauri.conf.json, and src-tauri/src/lib.rs.
Summarize the current project layout and scripts.
Do not modify files yet.

This matters because Tauri templates evolve. Let the generated project teach Claude Code the current structure instead of forcing an old blog-post layout onto a fresh app.

Minimal Tauri Config

tauri.conf.json connects the frontend dev server, the production frontend output, the desktop window, and the capability identifier. This Vite-oriented example is deliberately small.

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "TaskDesk",
  "version": "0.1.0",
  "identifier": "com.example.taskdesk",
  "build": {
    "beforeDevCommand": "npm run dev",
    "devUrl": "http://localhost:5173",
    "beforeBuildCommand": "npm run build",
    "frontendDist": "../dist"
  },
  "app": {
    "windows": [
      {
        "title": "TaskDesk",
        "width": 1000,
        "height": 700
      }
    ],
    "security": {
      "capabilities": ["main-capability"]
    }
  },
  "bundle": {
    "active": true,
    "targets": "all"
  }
}

Review three things whenever Claude Code changes this file: the identifier should not stay as a throwaway value, devUrl must match the Vite port, and frontendDist must point to the actual production build output.

Rust Commands

The notes app needs three commands: read a note, write a note, and list notes. The important part is not the file IO itself. The important part is refusing absolute paths, parent-directory traversal, and anything outside the app data directory.

// src-tauri/src/note_commands.rs
use serde::Serialize;
use std::{
    fs,
    path::{Component, Path, PathBuf},
};
use tauri::{AppHandle, Manager};

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NoteFile {
    name: String,
    path: String,
    bytes: u64,
    is_dir: bool,
}

fn reject_unsafe_relative(path: &Path) -> Result<(), String> {
    for component in path.components() {
        match component {
            Component::Normal(_) | Component::CurDir => {}
            _ => return Err("use a relative path inside app data".to_string()),
        }
    }
    Ok(())
}

fn app_data_root(app: &AppHandle) -> Result<PathBuf, String> {
    let root = app
        .path()
        .app_data_dir()
        .map_err(|error| format!("failed to get app data dir: {error}"))?;
    fs::create_dir_all(&root).map_err(|error| format!("failed to create app data dir: {error}"))?;
    root.canonicalize()
        .map_err(|error| format!("failed to resolve app data dir: {error}"))
}

fn existing_path(app: &AppHandle, relative: &str) -> Result<PathBuf, String> {
    let root = app_data_root(app)?;
    let requested = Path::new(relative);
    reject_unsafe_relative(requested)?;
    let full = root
        .join(requested)
        .canonicalize()
        .map_err(|error| format!("path does not exist: {error}"))?;

    if !full.starts_with(&root) {
        return Err("path escapes app data".to_string());
    }

    Ok(full)
}

fn writable_path(app: &AppHandle, relative: &str) -> Result<PathBuf, String> {
    let root = app_data_root(app)?;
    let requested = Path::new(relative);
    reject_unsafe_relative(requested)?;
    let full = root.join(requested);
    let parent = full.parent().ok_or("missing parent directory")?;
    fs::create_dir_all(parent).map_err(|error| format!("failed to create parent dir: {error}"))?;
    let parent = parent
        .canonicalize()
        .map_err(|error| format!("failed to resolve parent dir: {error}"))?;

    if !parent.starts_with(&root) {
        return Err("path escapes app data".to_string());
    }

    Ok(full)
}

#[tauri::command]
pub fn read_note(app: AppHandle, path: String) -> Result<String, String> {
    let safe_path = existing_path(&app, &path)?;
    fs::read_to_string(safe_path).map_err(|error| format!("failed to read note: {error}"))
}

#[tauri::command]
pub fn write_note(app: AppHandle, path: String, content: String) -> Result<(), String> {
    let safe_path = writable_path(&app, &path)?;
    fs::write(safe_path, content).map_err(|error| format!("failed to write note: {error}"))
}

#[tauri::command]
pub fn list_notes(app: AppHandle, dir: String) -> Result<Vec<NoteFile>, String> {
    let safe_dir = existing_path(&app, &dir)?;
    let mut files = Vec::new();

    for entry in fs::read_dir(safe_dir).map_err(|error| format!("failed to read dir: {error}"))? {
        let entry = entry.map_err(|error| format!("failed to read entry: {error}"))?;
        let metadata = entry
            .metadata()
            .map_err(|error| format!("failed to read metadata: {error}"))?;
        let name = entry.file_name().to_string_lossy().to_string();

        files.push(NoteFile {
            name: name.clone(),
            path: name,
            bytes: metadata.len(),
            is_dir: metadata.is_dir(),
        });
    }

    Ok(files)
}

Register the commands in lib.rs. The Tauri docs call out that commands in a separate module should be public and registered through generate_handler!.

// src-tauri/src/lib.rs
mod note_commands;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            note_commands::read_note,
            note_commands::write_note,
            note_commands::list_notes
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Frontend Invoke Calls

In the frontend, use invoke from @tauri-apps/api/core. Keep the wrapper small so UI components do not repeat command names or payload shapes.

// src/lib/notesApi.ts
import { invoke } from "@tauri-apps/api/core";

export type NoteFile = {
  name: string;
  path: string;
  bytes: number;
  isDir: boolean;
};

export const notesApi = {
  read(path: string) {
    return invoke<string>("read_note", { path });
  },
  write(path: string, content: string) {
    return invoke<void>("write_note", { path, content });
  },
  list(dir = ".") {
    return invoke<NoteFile[]>("list_notes", { dir });
  },
};

Here is a minimal React screen:

// src/App.tsx
import { useState } from "react";
import { notesApi } from "./lib/notesApi";

export default function App() {
  const [content, setContent] = useState("");
  const [message, setMessage] = useState("Ready");
  const fileName = "daily-note.txt";

  async function loadNote() {
    try {
      setContent(await notesApi.read(fileName));
      setMessage("Loaded");
    } catch (error) {
      setMessage(String(error));
    }
  }

  async function saveNote() {
    try {
      await notesApi.write(fileName, content);
      setMessage("Saved");
    } catch (error) {
      setMessage(String(error));
    }
  }

  return (
    <main>
      <h1>TaskDesk</h1>
      <textarea value={content} onChange={(event) => setContent(event.target.value)} />
      <button onClick={loadNote}>Load</button>
      <button onClick={saveNote}>Save</button>
      <p>{message}</p>
    </main>
  );
}

And the same boundary works in Svelte:

<!-- src/App.svelte -->
<script lang="ts">
  import { notesApi } from "./lib/notesApi";

  let content = "";
  let message = "Ready";
  const fileName = "daily-note.txt";

  async function loadNote() {
    try {
      content = await notesApi.read(fileName);
      message = "Loaded";
    } catch (error) {
      message = String(error);
    }
  }

  async function saveNote() {
    try {
      await notesApi.write(fileName, content);
      message = "Saved";
    } catch (error) {
      message = String(error);
    }
  }
</script>

<main>
  <h1>TaskDesk</h1>
  <textarea bind:value={content}></textarea>
  <button on:click={loadNote}>Load</button>
  <button on:click={saveNote}>Save</button>
  <p>{message}</p>
</main>

Permissions and Capabilities

Tauri v2 capabilities define which windows or webviews receive which permissions. If you use the File System plugin directly from the frontend, keep the scope narrow and name it in src-tauri/capabilities.

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "main-capability",
  "description": "Main window permissions for TaskDesk.",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "fs:allow-app-read-recursive",
    "fs:allow-app-write-recursive"
  ]
}

The sample in this article uses Rust commands for file access, so broad frontend file-system permissions are not required. Do not add fs:allow-download-read-recursive, document-folder scopes, or global-looking permissions just because the app writes notes. Add plugin permissions only when the frontend actually calls that plugin.

Use Claude Code as a reviewer after permission changes:

Review only. Do not edit files.
Inspect src-tauri/capabilities and src-tauri/src/note_commands.rs.
List every frontend-exposed API and every Rust command.
For each one, state which file paths it can touch.
Flag absolute paths, parent-directory traversal, broad wildcards, and writes outside app data.
Suggest the smallest permission set that still supports the feature.

If possible, run this in a fresh Claude Code session with only the diff and the relevant files. A separate review pass is less likely to defend the implementation it just wrote.

Build and Test

Keep frontend, Rust, and packaging checks separate:

npm run build
cd src-tauri
cargo test
cd ..
npm run tauri build

npm run build proves the Vite app compiles. cargo test proves the Rust side and its tests work. npm run tauri build creates the desktop bundle and is the right place to catch packaging, signing, and target-specific issues.

Add focused tests around path validation. Testing the whole AppHandle path flow is heavier, but the dangerous input rules can be tested as pure Rust.

#[cfg(test)]
mod tests {
    use super::reject_unsafe_relative;
    use std::path::Path;

    #[test]
    fn rejects_parent_directory() {
        assert!(reject_unsafe_relative(Path::new("../secret.txt")).is_err());
    }

    #[test]
    fn accepts_simple_relative_path() {
        assert!(reject_unsafe_relative(Path::new("notes/today.txt")).is_ok());
    }
}

When a check fails, give Claude Code the exact layer:

Only `cargo test` is failing.
Read src-tauri/src/note_commands.rs and fix the path validation tests with the smallest diff.
Do not change capabilities, React UI, or tauri.conf.json.
End with the exact verification commands I should run.

Practical Use Cases

Use case one is a private notes or daily log app. It is the best beginner project because it exercises the whole Tauri boundary without needing login, sync, or a server. Start with app data storage, then add export later.

Use case two is a CSV or Markdown converter. The user selects local files, the app transforms them, and the result is written locally. This is compelling for companies that do not want customer files uploaded to a browser service.

Use case three is a developer log viewer. The UI can search and filter logs while Rust handles local file access and large-file processing. The same pattern applies to build reports, test artifacts, and static analysis output.

Use case four is an offline-first field tool. Event check-in, factory inspection, and store inventory tools often need to capture data when the network is unreliable. Tauri can store locally first, but sync conflict rules still need product design.

Pitfalls to Avoid

The first pitfall is confusing plugin permissions with Rust command safety. Capabilities help, but they do not replace Rust-side path validation.

The second pitfall is asking Claude Code to “build the whole app.” That usually produces a wide diff touching UI, config, permissions, dependencies, and packaging at once. Ask for one command, one screen, one test, or one capability change.

The third pitfall is leaving development settings in production. App identifier, window title, icon, signing, update plan, build output, and package metadata all need a release review.

The fourth pitfall is testing on only one OS. WebView behavior, paths, installers, file dialogs, and signing differ across platforms. At minimum, run the app on every OS you plan to distribute.

Monetization and Next Step

Tauri readers usually have implementation intent: they want a local tool, a packaged internal app, or a safer replacement for spreadsheet workflows. ClaudeCodeLab packages these patterns into products and templates and can help teams through Claude Code training and consultation. For solo builders, prompt templates make permission review repeatable. For teams, a repository-based session is better because capability rules, CLAUDE.md, build commands, and release checks depend on the real app.

In the tested workflow behind this article, the most stable order was: design the Rust path boundary first, wrap it with typed TypeScript invoke calls second, then review capabilities last. Starting with the UI felt faster for a few minutes, but it created larger rewrites when storage and permission rules became clear. With Tauri and Claude Code, the boundary is the product foundation.

#Claude Code #Tauri #Rust #desktop apps #frontend
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.