File System API
Learn how to read, write, and manage files and directories in Tauri v2 using the File System plugin with React and Vite
The File System API in Tauri v2 gives your application direct, native access to the user’s files and directories. It is implemented through the @tauri-apps/plugin-fs JavaScript package and the tauri-plugin-fs Rust crate. The plugin enforces a layered security model—your app can only touch paths you have explicitly allowed, and path traversal attacks are blocked by default. This keeps user data safe while still giving you the full power of the local file system.
Introduction
Desktop applications need to work with files: reading configuration, saving user‑generated documents, caching data, or letting users organise their content. In a web browser, the File System Access API sandboxes access behind user‑initiated pickers. Tauri removes that browser‑level sandbox but replaces it with a permissions system you control. The dedicated Introduction covers installation and the security layer in isolation. You decide exactly which directories the frontend can read or write, using base directories and scope rules that are enforced at the native layer.
The plugin supports text and binary files, low‑level file handles, and full directory management. It also respects platform‑specific restrictions: on mobile, your app is confined to its sandboxed storage unless you declare external storage permissions; on desktop, the $RESOURCES folder is read‑only on Linux and macOS, and on Windows an admin‑installed app may need elevated rights for write access there.
Setting Up the Plugin
Adding the plugin is a small, sequential process. The steps below will get the plugin installed, permitted, and ready to use in both the Rust backend and the React frontend.
Install the File System plugin
The simplest way is to use the Tauri CLI, which handles both the Rust crate and the JavaScript bindings. Choose your package manager below.
npm run tauri add fs
Automatic setup handles everything:
The tauri add fs command automatically adds the Rust dependency, the npm package, and any required permissions to your capability files. If you use this method, you can skip directly to using the API.
Configure permissions and scope
The plugin will not work until your app declares the necessary capabilities. Create or edit a JSON file inside src-tauri/capabilities/—for example, src-tauri/capabilities/default.json:
{
"identifier": "default",
"description": "Default capability for the main window",
"windows": ["main"],
"permissions": [
"fs:default",
{
"identifier": "fs:scope",
"allow": [{ "path": "$APPDATA/**" }]
}
]
}
fs:defaultgives your app permission to call the plugin’s functions.fs:scopedefines which paths the frontend is allowed to access. The example above allows everything inside the app’s data directory. You can add multiple scope entries to permit access to specific folders.
Missing scope blocks all access:
Without an fs:scope entry, the plugin rejects every file operation with a path not allowed error. You must declare at least one allowed path—even if it is a broad pattern like **/* for development.
(Optional) Dynamically expand scope from Rust
Sometimes you want to grant access to a directory that is only known at runtime—for example, a folder the user just picked through a dialog. You can do this in your setup closure:
use tauri_plugin_fs::FsExt;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_fs::init())
.setup(|app| {
let scope = app.fs_scope();
// Allow read-only access to a known directory
scope.allow_directory("/path/to/directory", false);
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The second parameter controls write access (false means read‑only). Dynamically allowed paths still respect the plugin’s path traversal protection.
Understanding Base Directories
The safest way to build file paths is with BaseDirectory, an enum that maps to standard system locations. Instead of hard‑coding absolute paths, you provide a logical folder and let Tauri resolve the real location for each platform.
| BaseDirectory variant | Typical location (desktop) |
|---|---|
AppData | App‑specific data directory |
AppConfig | App‑specific config directory |
Home | User’s home folder |
Desktop | User’s desktop |
Document | User’s documents |
Download | User’s downloads |
Resource | The app’s bundled resources (read‑only on Linux/macOS) |
Temp | Temporary directory |
Cache | App cache directory |
Any API that accepts a baseDir option will join your relative path with the chosen base directory. This prevents accidental access to unrelated parts of the file system and works across Windows, macOS, and Linux.
import { readTextFile, BaseDirectory } from "@tauri-apps/plugin-fs";
// Reads ~/.config/com.myapp.app/config.json (or equivalent on Windows)
const contents = await readTextFile("config.json", {
baseDir: BaseDirectory.AppConfig,
});
You can also construct paths manually with the @tauri-apps/api/path package, but using BaseDirectory is usually simpler and more secure.
Reading Files
Tauri offers three ways to read a file: the high‑level convenience functions readTextFile and readFile, and the lower‑level open API that returns a file handle. Which one you pick depends on how much control you need and whether the data is text or binary.
Reading an Entire Text File
readTextFile reads the complete file contents as a UTF‑8 string. It is the right choice for configuration files, logs, or any human‑readable data.
import { useState } from "react";
import { readTextFile, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function ReadConfig() {
const [text, setText] = useState("");
async function loadConfig() {
try {
const content = await readTextFile("settings.json", {
baseDir: BaseDirectory.AppConfig,
});
setText(content);
} catch (error) {
console.error("Failed to read config:", error);
}
}
return (
<div>
<button onClick={loadConfig}>Load Config</button>
{text && <pre>{text}</pre>}
</div>
);
}
When you click the button, the function attempts to read settings.json from the app’s configuration directory. If the file exists and the path is allowed by your scope, the full text appears on screen. The catch block will catch permission denials, missing files, or decoding errors.
Large text files block the UI:
readTextFile loads the entire file into memory. For very large files (hundreds of megabytes) this can slow down your app or even crash it. Use the file‑handle approach described later to read in chunks.
Reading a Binary File
When you need raw bytes—an image, a database file, a serialised blob—use readFile. It returns a Uint8Array.
import { useState } from "react";
import { readFile, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function ReadImageInfo() {
const [size, setSize] = useState(0);
async function loadImage() {
const data = await readFile("avatar.png", {
baseDir: BaseDirectory.AppData,
});
setSize(data.byteLength);
}
return (
<div>
<button onClick={loadImage}>Check Image Size</button>
{size > 0 && <p>Image size: {size} bytes</p>}
</div>
);
}
The function reads avatar.png from the app’s data directory. The byte length gives you the file size. You could then pass the Uint8Array to a Blob or a canvas, but that is beyond the scope of this document.
Don't use readTextFile for binary content:
Calling readTextFile on a binary file will try to decode the bytes as UTF‑8. Non‑text bytes will be replaced with the Unicode replacement character, corrupting the data. Always use readFile when you are not sure the content is plain text.
Using a File Handle for More Control
The open function gives you a FileHandle. You can open a file in read mode, query its size with stat(), and read exactly the bytes you need. This is how you handle large files without loading everything at once.
import { open, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function ReadChunk() {
async function readFirstKilobyte() {
const file = await open("bigfile.bin", {
read: true,
baseDir: BaseDirectory.AppData,
});
const stat = await file.stat();
const buffer = new Uint8Array(Math.min(1024, stat.size));
await file.read(buffer);
await file.close();
console.log("First bytes:", buffer.slice(0, 20));
}
return <button onClick={readFirstKilobyte}>Read First 1 KB</button>;
}
file.stat() returns an object with size, isDirectory, isFile, and platform‑specific timestamps. By allocating a buffer of only the required length, you avoid consuming memory proportional to the entire file. The file must be closed with file.close() when you are done; otherwise, the operating system may keep the file locked.
Writing Files
Writing files follows the same pattern: convenience functions for full‑content writes, and a file handle for incremental writes, appending, or conditional creation.
Writing a Text File
writeTextFile takes a string and writes it atomically to the given path. If the file already exists, it is overwritten.
import { useState } from "react";
import { writeTextFile, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function SaveNote() {
const [note, setNote] = useState("");
const [saved, setSaved] = useState(false);
async function handleSave() {
await writeTextFile("note.txt", note, {
baseDir: BaseDirectory.AppData,
});
setSaved(true);
}
return (
<div>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Write your note..."
/>
<button onClick={handleSave}>Save</button>
{saved && <p>Note saved.</p>}
</div>
);
}
The note is saved in the app’s data folder. There is no file picker—the path is fixed. If you need to let the user choose a location, combine this with the Dialog API.
Writing Binary Data
writeFile accepts a Uint8Array and writes the raw bytes to disk. It is the correct choice for images, serialised data, or any non‑text content.
import { writeFile, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function SaveBinary() {
async function save() {
const bytes = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello"
await writeFile("greeting.bin", bytes, {
baseDir: BaseDirectory.AppData,
});
console.log("Binary data written.");
}
return <button onClick={save}>Write Binary File</button>;
}
Creating a New File with a Handle
The create function truncates the file if it already exists and returns a FileHandle. You then write data and close the handle.
import { create, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function CreateExample() {
async function createAndWrite() {
const file = await create("report.txt", {
baseDir: BaseDirectory.AppData,
});
await file.write(new TextEncoder().encode("Report generated."));
await file.close();
}
return <button onClick={createAndWrite}>Create Report</button>;
}
Appending to an Existing File
Pass append: true when opening a file, and every subsequent write call adds data at the end instead of overwriting.
import { open, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function LogWriter() {
async function appendLog() {
const file = await open("app.log", {
append: true,
create: true,
baseDir: BaseDirectory.AppData,
});
const timestamp = new Date().toISOString();
await file.write(new TextEncoder().encode(`${timestamp} - User action\n`));
await file.close();
}
return <button onClick={appendLog}>Write Log Entry</button>;
}
Setting create: true makes sure the file is created if it does not yet exist. Without it, open throws when the file is missing. The append option implicitly enables write access, so you do not need to set write: true separately.
Truncate mode:
When you need to clear a file before writing, use truncate: true along with write: true. This sets the file length to zero and gives you a clean slate. It is useful when you want to rebuild a file with multiple write calls without a remnant of the old content.
Working with Directories
Real applications rarely store everything at the top level. The plugin provides functions to create, list, and delete directories, as well as to inspect individual entries.
Creating a Directory
mkdir creates a directory (and any necessary parents) at the specified path. If the directory already exists, it does nothing and does not throw.
import { mkdir, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function CreateProjectDir() {
async function setup() {
await mkdir("projects/my-app", {
baseDir: BaseDirectory.AppData,
recursive: true,
});
console.log("Directory structure created.");
}
return <button onClick={setup}>Create Project Folder</button>;
}
The recursive: true option creates intermediate directories if they are missing. Without it, mkdir throws if any parent directory does not exist.
Listing Directory Contents
readDir returns an array of DirEntry objects, each with a name, isFile, and isDirectory property. You can then decide how to render the listing.
import { useState } from "react";
import { readDir, BaseDirectory } from "@tauri-apps/plugin-fs";
interface EntryInfo {
name: string;
isDir: boolean;
}
export default function FileExplorer() {
const [entries, setEntries] = useState<EntryInfo[]>([]);
async function listAppData() {
const result = await readDir("", {
baseDir: BaseDirectory.AppData,
});
setEntries(
result.map((entry) => ({
name: entry.name,
isDir: entry.isDirectory,
}))
);
}
return (
<div>
<button onClick={listAppData}>List App Data</button>
<ul>
{entries.map((e) => (
<li key={e.name}>
{e.isDir ? "📁" : "📄"} {e.name}
</li>
))}
</ul>
</div>
);
}
Calling readDir("") with a base directory of AppData lists the top‑level contents of the app’s data folder. Each entry tells you whether it is a file or directory, so you can build a tree‑style explorer.
readDir does not recurse:
readDir only lists the immediate children. To walk an entire directory tree, you need to recursively call readDir on subdirectories. Doing this for very deep trees can be slow, so consider adding a depth limit.
Removing a Directory
remove can delete both files and directories. When you pass recursive: true, it deletes a directory even if it still contains files or subdirectories.
import { remove, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function Cleanup() {
async function deleteTemp() {
await remove("temp", {
baseDir: BaseDirectory.AppData,
recursive: true,
});
console.log("Temp directory removed.");
}
return <button onClick={deleteTemp}>Delete Temp Folder</button>;
}
Be careful with recursive: true—there is no undo. The operation is permanent, just like deleting a folder in your operating system’s file manager.
File Operations
Beyond reading and writing, you often need to check existence, get metadata, move files, or copy them. These file operations are all available as top‑level functions from the plugin.
Checking Existence
exists tells you whether a file or directory is present at a given path without throwing an error.
import { exists, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function ConfigGuard() {
async function check() {
const present = await exists("config.json", {
baseDir: BaseDirectory.AppConfig,
});
console.log(present ? "Config exists" : "No config yet");
}
return <button onClick={check}>Check Config</button>;
}
Retrieving File Metadata
stat returns an object with size, isFile, isDirectory, atime, mtime, ctime, and birthtime. This is useful for detecting changes or displaying file information.
import { stat, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function FileInfo() {
async function inspect() {
const info = await stat("report.txt", {
baseDir: BaseDirectory.AppData,
});
console.log(`Size: ${info.size} bytes, Last modified: ${info.mtime}`);
}
return <button onClick={inspect}>Inspect File</button>;
}
Copying a File
copyFile duplicates a file from a source path to a destination path. It does not copy directories; for that, you would need to manually iterate and recreate the tree.
import { copyFile, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function BackupConfig() {
async function backup() {
await copyFile("settings.json", "settings.backup.json", {
fromBaseDir: BaseDirectory.AppConfig,
toBaseDir: BaseDirectory.AppData,
});
console.log("Config backed up.");
}
return <button onClick={backup}>Backup Settings</button>;
}
Renaming or Moving a File
rename changes the name and/or location of a file or directory. If the destination already exists, it is overwritten.
import { rename, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function RenameNote() {
async function renameFile() {
await rename("old-name.txt", "new-name.txt", {
oldBaseDir: BaseDirectory.AppData,
newBaseDir: BaseDirectory.AppData,
});
console.log("File renamed.");
}
return <button onClick={renameFile}>Rename File</button>;
}
Best Practices
The File System API is powerful, but its power comes with responsibilities. Following these best practices keeps your app reliable, secure, and pleasant to use.
Limit Scope to What Your App Actually Needs
In a capability file, the scope patterns define the attack surface. Avoid using "**/*" in production—it grants access to every file on the system the user can read. Instead, scope down to the specific directories your feature needs, such as "$APPDATA/**" for app data or "$HOME/Documents/**" for user documents. If a feature only needs read access, use fs:scope with allow entries that end with a single asterisk ("$HOME/Documents/*") and keep write‑enabled paths separate.
Broad scope is a security risk:
A malicious dependency or an XSS vulnerability in your frontend could abuse a wide scope to read or exfiltrate sensitive files. Restricting scope minimises the blast radius. For development convenience, you can use a broader scope, but always tighten it before shipping.
Prefer BaseDirectory Over Absolute Paths
Absolute paths break when your app runs on a different operating system or under a different user account. BaseDirectory resolves to the correct location on every platform. Moreover, the plugin’s path traversal protection works best with relative paths anchored to a base directory—it can detect .. attempts and reject them.
Handle Errors Gracefully
Every file operation can fail: the disk may be full, the file may be locked by another process, or the user may have revoked a permission. Always wrap calls in try/catch and present user‑friendly messages instead of crashing silently. For example, a missing file is often a recoverable situation, not a reason to show an error to the user.
try {
const content = await readTextFile("user-config.json", {
baseDir: BaseDirectory.AppConfig,
});
// use content
} catch (error) {
// Could be a missing file or a permissions issue
console.warn("Could not read config, using defaults.");
}
Avoid Blocking the UI with Large Files
Reading or writing a multi‑gigabyte file in one shot will freeze your frontend and may even crash the app. Use the open API with a file handle and process the data in manageable chunks. For extremely large files, consider performing the I/O on the Rust side via a Tauri command and only sending the necessary data back to the frontend.
Respect Platform‑Specific Constraints
- Android: Access is limited to the app’s internal storage unless you add
READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGEpermissions inAndroidManifest.xml. Use thepublicordownloadsbase directories with care. - iOS: You must include a
PrivacyInfo.xcprivacyfile with theNSPrivacyAccessedAPICategoryFileTimestampkey and reasonC617.1to comply with Apple’s privacy manifest requirements. - macOS: To access files outside the app’s sandbox using absolute paths, you need a temporary entitlement (
com.apple.security.temporary-exception.files.absolute-path.read-write) for development, and proper security‑scoped bookmarks for distribution. - Windows: MSI/NSIS installers in
perMachineorbothmode require admin rights for write access inside the$RESOURCESdirectory.
Your setup is correct when...:
You know your plugin is configured properly if you can read a known file inside an allowed scope without any errors. Try reading a text file from BaseDirectory.AppConfig first—if that succeeds, the plugin, permissions, and base directory resolution are all working.
The File System API often pairs with the Dialog API. Letting users pick a file or folder through a native dialog and then operating on the returned path within a dynamically expanded scope is a clean, user‑respecting pattern.
Introduction to the File System API
Understand the Tauri v2 File System API plugin, its purpose, common use cases, installation, and permission configuration.
Reading Files
A complete guide to reading text, binary, and JSON files in a Tauri v2 application using the file system plugin from a React frontend
Writing Files
Write text, binary, and JSON data to disk from your Tauri v2 application with the file system plugin, including overwrite and append modes.
Working with Directories
Learn how to create, read, delete, and check existence of directories in Tauri v2 using the File System plugin.
File Operations
How to copy, move, rename, and delete files using the Tauri file system plugin with a React and Vite frontend.
Best Practices for the File System API
Production-grade patterns for Tauri v2 file system access – handling permissions, large files, errors, and security while keeping the frontend responsive