Tauri File System Access: Read, Write & Native Dialogs | Rust + React Tutorial (Lesson 35)
Video: Tauri File System Access: Read, Write & Native Dialogs | Rust + React Tutorial (Lesson 35) by Taught by Celeste AI - AI Coding Coach
tauri-plugin-fsforreadTextFile/writeTextFile/exists.tauri-plugin-dialogfor native open/save dialogs. The two plugins every desktop app needs.
A web app can't read files from the user's disk; that's a security boundary the browser enforces. Desktop apps need to. Tauri provides this via two plugins: fs for the I/O itself, dialog for the native file pickers.
This lesson covers both, plus the permissions model that lets Tauri 2 control which paths your frontend can touch.
Install the plugins
npm install @tauri-apps/plugin-fs @tauri-apps/plugin-dialog
cd src-tauri
cargo add tauri-plugin-fs tauri-plugin-dialog
Two halves of each plugin: a JS package for the frontend API, a Rust crate for the backend. Both are needed.
Then register the Rust plugins in your Builder:
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![/* commands */])
.run(tauri::generate_context!())
.expect("error while running tauri application");
Capabilities
Tauri 2 has a permission model. Plugins declare what they can do; your app's capability file declares what you allow.
src-tauri/capabilities/default.json:
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-exists",
"dialog:allow-open",
"dialog:allow-save"
]
}
Without these permissions in the capability file, the frontend can't call the corresponding plugin commands. Each plugin's docs list the permissions it offers. Pick the narrowest set that covers your needs.
Reading a file from the frontend
import { readTextFile } from "@tauri-apps/plugin-fs";
const content = await readTextFile("/Users/me/notes.txt");
console.log(content);
readTextFile(path) returns the file's contents as a string. The path is OS-specific — /Users/... on macOS, C:\Users\... on Windows.
For a portable approach, use the path plugin:
import { join, documentDir } from "@tauri-apps/plugin-fs";
const dir = await documentDir();
const path = await join(dir, "notes.txt");
const content = await readTextFile(path);
documentDir() returns the OS's Documents directory; join builds an OS-correct path. Hardcoded paths are fragile; the path plugin keeps your app cross-platform.
Writing a file
import { writeTextFile } from "@tauri-apps/plugin-fs";
await writeTextFile("/Users/me/notes.txt", "Hello, file!");
Overwrites if the file exists, creates it if not. There's also writeFile for arbitrary bytes (Uint8Array).
Other useful operations
import { exists, mkdir, remove, readDir, BaseDirectory } from "@tauri-apps/plugin-fs";
if (!(await exists("notes.txt", { baseDir: BaseDirectory.Document }))) {
await mkdir("notes-folder", { baseDir: BaseDirectory.Document, recursive: true });
}
const entries = await readDir("notes-folder", { baseDir: BaseDirectory.Document });
for (const entry of entries) {
console.log(entry.name);
}
await remove("old-notes.txt", { baseDir: BaseDirectory.Document });
BaseDirectory is an enum of OS-standard directories: AppData, AppConfig, AppLocalData, Document, Download, Home, Picture, etc. Using a baseDir lets you write paths relative to a known location and avoid hardcoding.
Open dialog
import { open } from "@tauri-apps/plugin-dialog";
const path = await open({
multiple: false,
filters: [{ name: "Text", extensions: ["txt", "md"] }],
});
if (path) {
const content = await readTextFile(path as string);
console.log(content);
}
open() returns a string | null (or string[] | null if multiple: true). The return is null if the user cancelled.
filters controls the file type dropdown in the OS dialog. Each filter has a name and an array of extensions.
Save dialog
import { save } from "@tauri-apps/plugin-dialog";
const path = await save({
defaultPath: "untitled.txt",
filters: [{ name: "Text", extensions: ["txt"] }],
});
if (path) {
await writeTextFile(path, "content");
}
save() shows a "Save As..." dialog. The user picks a destination; path is the chosen filename or null on cancel.
Confirm and message dialogs
import { confirm, message } from "@tauri-apps/plugin-dialog";
const ok = await confirm("Delete this file?", { title: "Confirm", kind: "warning" });
if (ok) {
await remove(path);
}
await message("File saved.", { kind: "info" });
Native confirmation and notification dialogs. Useful for destructive actions and important alerts.
Practical example: a notes app
Open file:
async function openFile() {
const path = await open({
filters: [{ name: "Text", extensions: ["txt", "md"] }],
});
if (typeof path === "string") {
const content = await readTextFile(path);
setText(content);
setCurrentPath(path);
}
}
Save file (Save → Save As if no path yet):
async function saveFile() {
let path = currentPath;
if (!path) {
path = await save({ defaultPath: "untitled.txt" });
}
if (typeof path === "string") {
await writeTextFile(path, text);
setCurrentPath(path);
}
}
That's the entire shape of a notes editor's File menu.
Doing it from Rust instead
You can also do file I/O from a Rust command:
#[tauri::command]
fn read_note(path: String) -> Result<String, String> {
std::fs::read_to_string(&path).map_err(|e| e.to_string())
}
Frontend:
const content = await invoke<string>("read_note", { path });
When to do it from Rust vs. the plugin?
- Plugin (frontend
readTextFile) — for simple, direct I/O the user requested. The permission system controls which paths you can touch. - Custom Rust command — when you need to do additional work (parse, filter, encrypt) before returning the text. Or when you need access to paths outside the plugin's permission model.
Most apps use both, picking based on what each call needs.
Common mistakes
Forgetting to grant capabilities. The plugin call returns an error like "fs.read_text_file not allowed by ACL." Check capabilities/default.json.
Hardcoding paths. /Users/me/... works on your machine, breaks on every other macOS user's machine. Use BaseDirectory or the path plugin.
Not checking for null after dialogs. open() returns null if the user cancelled. Always handle that case.
Reading huge files with readTextFile. Loads the entire file into memory. For multi-megabyte files, stream them via a custom Rust command.
Writing files concurrently. Two writeTextFile calls to the same path race. Use a single owner per file, or use atomic file writes (write to a temp file, rename).
What's next
Next lesson: a notes app from scratch. Putting file system access, dialogs, and a React UI together into a usable note-taking application.
Recap
@tauri-apps/plugin-fs for read/write/exists/dir operations. @tauri-apps/plugin-dialog for native open/save/confirm/message dialogs. Capability file controls which operations are allowed. BaseDirectory enum for portable paths. Combine plugin calls with custom Rust commands for richer behaviour.
Next: notes app. See you in the next one.