Introduction to the File System API

Understand the Tauri v2 File System API plugin, its purpose, common use cases, installation, and permission configuration.

Desktop applications need to read configuration files, save user data, process documents, and export reports. Web browsers cannot do this freely because they run inside a security sandbox. Tauri bridges that gap by giving your React frontend controlled, programmatic access to the real file system — but only after you explicitly declare what your app is allowed to touch. This introduction explains why the File System API exists in Tauri v2, how the security layer works around it, and how to install and configure the plugin so you can start reading and writing files from your React components.

What is the File System API?

The Tauri File System API is a plugin (@tauri-apps/plugin-fs) that exposes a set of functions for file and directory operations: reading, writing, creating, deleting, listing directory contents, and querying metadata. It works from both the Rust backend and the JavaScript frontend. The key difference from a regular Node.js or Deno file system module is that every access request passes through Tauri's permission system. Your frontend code never talks directly to the operating system. It calls JavaScript functions from the plugin, which send inter-process messages to the Rust backend. The Rust side checks whether the operation is allowed by the capabilities you defined, then executes it or rejects it. This layered architecture means you can safely give file system access to your app's UI without opening a hole for malicious scripts. You decide exactly which directories and operations are permitted — nothing more.

Why You Need It in a Tauri App

Consider a markdown editor. The user opens a .md file from their Documents folder, edits it, and saves it back. Without the File System API, you would have to rely on the browser's <input type="file"> and download prompts, which cannot overwrite the original file in place or remember where the file came from. The editor feels like a website, not a real application. The File System API removes these artificial barriers. It lets your app:

  • Read and write to paths the user chooses (with their explicit permission in some cases).
  • Persist file handles across sessions so the user doesn't have to re-select files every time.
  • Work with binary data (images, databases, logs) exactly as a native program would.
  • List directory contents to build file browsers or project explorers.

Web File APIs vs. Tauri File System:

Browsers have their own File System Access API (the one used by showOpenFilePicker()), but it is only available in Chromium-based browsers and requires a secure context. Tauri's API is a native plugin that gives you consistent, cross-platform access on Windows, macOS, and Linux without browser compatibility concerns.

Common Use Cases

Several categories of desktop applications become possible only when you have reliable file system access:

  • Document editors — IDEs, text editors, and office tools that load, modify, and save user files directly to disk.
  • Media managers — Photo organizers, music players, or video libraries that scan directories, read metadata, and manage large binary files.
  • Data tools — CSV/JSON processors, database browsers, or log analyzers that import and export files without manual upload/download steps.
  • Configuration and settings — Storing application preferences in a user-accessible JSON file, exporting profiles, or loading custom themes from a folder.
  • Backup and sync utilities — Copying, moving, or archiving files across directories on the user's machine.
  • Game modding tools — Reading game asset directories, patching files, and saving modified assets back. In each case, the user expects the app to behave like any other installed program — not like a web page that forgets file paths after a refresh. The File System API is the foundation that enables that native experience.

Setting Up the File System Plugin

You need to install both the Rust crate (the backend logic) and the JavaScript package (the frontend bindings), then initialize the plugin in your Tauri application. The JavaScript package must be installed in your frontend project (React + Vite) while the Rust crate goes into the src-tauri directory.

1

Install the JavaScript package

Use your preferred package manager to add @tauri-apps/plugin-fs to the frontend project. This is where your Vite + React code lives — not inside src-tauri.

npm install @tauri-apps/plugin-fs
2

Add the Rust crate dependency

Navigate to the src-tauri directory and add the tauri-plugin-fs crate using Cargo.

cargo add tauri-plugin-fs

This updates src-tauri/Cargo.toml with the plugin dependency. No manual file editing is required.

3

Initialize the plugin in lib.rs

Open src-tauri/src/lib.rs and register the plugin inside the builder chain. This is what connects the Rust backend to the JavaScript calls from your frontend.

src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
4

Verify the setup

After installing both packages and adding .plugin(tauri_plugin_fs::init()), run your app in development mode. If the plugin is properly registered, calling any File System API function from the frontend will return a result (or throw a permission error, which means the plugin is alive — we'll fix permissions next). A build error about missing symbols or unresolved imports indicates a missed step.

Plugin installed correctly:

If your app compiles and runs without Rust linkage errors, and calling a simple function like stat() returns an object (even if it's a permission error), the plugin is active and ready for permission configuration.

Understanding Permissions

Tauri v2 has a capabilities-based security model. Every sensitive operation — file system access included — must be explicitly allowed. This is not optional. Without proper permission configuration, even a correctly installed plugin will deny all file operations.

How the Permission Model Works

When your React code calls readFile('/path/to/file'), the JavaScript plugin sends a command to the Rust backend. Before the Rust code touches the disk, it checks the calling window's capabilities. Capabilities are defined in JSON files inside src-tauri/capabilities/. Each file lists which windows (or all windows) have which permissions, and for file system operations, which directories (scopes) they can access. The mental model: think of a capability as a keycard. The card states, "This window is allowed to use the fs:read-file permission, but only for paths under /home/user/Documents." Tauri enforces this rule at runtime. If the path falls outside the allowed scope, the operation is blocked before it ever reaches the OS.

Configuring a Capability File

Create or modify a capability file (e.g., src-tauri/capabilities/default.json) to grant file system access. A minimal setup that allows reading and writing anywhere on the system looks like this:

src-tauri/capabilities/default.json
{
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default"
  ]
}

The "fs:default" permission enables all basic file operations (read, write, exists, stat, etc.) but does not grant access to arbitrary paths. By default, the plugin only allows operations inside the app's designated data directories (like BaseDirectory.AppData). To read or write files in user-chosen locations, you must define a scope. A scope is a list of allowed paths — either explicit absolute paths or glob patterns — that widen what the permission covers. Add a scope entry like this:

src-tauri/capabilities/default.json
{
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "fs:scope",
      "allow": [{ "path": "$HOME/**" }]
    },
    "fs:default"
  ]
}

This grants access to everything under the user's home directory. The $HOME variable is resolved at runtime to the actual home path on each OS. You can use $APPDATA, $DESKTOP, $DOCUMENTS, and other predefined variables, or specify absolute paths like "/path/to/project/**". The ** glob matches any number of subdirectories.

Avoid overly broad scopes:

While "$HOME/**" is convenient for development, granting access to the entire home directory is risky in a production app. A bug in your frontend code could accidentally delete or overwrite important files. Always scope down to the specific directories your app actually needs, such as "$DOCUMENTS/my-app/**".

How Paths are Validated

Tauri automatically blocks path traversal attacks. Paths containing .. or resolving to a parent directory outside the allowed scope are rejected. You cannot, for example, open "$DOCUMENTS/../secret.file" to escape the allowed directory. This protection is built into the plugin and cannot be disabled. Additionally, any path you pass must either be relative to a registered base directory (when using the baseDir option) or an absolute path that falls within a permitted scope. If neither condition is satisfied, the operation fails with a permission error.

Missing scope causes PermissionDenied:

If you forget to add a fs:scope entry and try to read an absolute path like "/home/user/file.txt", the operation will fail with a PermissionDenied error. This is not an OS-level permission issue — it's Tauri's capability system blocking the call because no scope covered that path.

A First Complete Example

After installing the plugin and configuring permissions, you can read a file from the frontend. This example reads a text file from the user's Documents folder and displays its content in a React component. It assumes you have already added the scope "$DOCUMENTS/**" (or a more specific path) to your capability file. Rust side (src-tauri/src/lib.rs) — ensures the plugin is registered (already done during setup):

src-tauri/src/lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Capability file (src-tauri/capabilities/default.json):

src-tauri/capabilities/default.json
{
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "fs:scope",
      "allow": [{ "path": "$DOCUMENTS/**" }]
    },
    "fs:default"
  ]
}

React frontend (src/App.tsx):

src/App.tsx
import { useState } from 'react';
import { readTextFile } from '@tauri-apps/plugin-fs';
import { documentDir } from '@tauri-apps/api/path';

function App() {
  const [content, setContent] = useState<string>('');
  const [error, setError] = useState<string>('');

  async function loadFile() {
    try {
      const docDir = await documentDir();
      const text = await readTextFile(`${docDir}example.txt`);
      setContent(text);
      setError('');
    } catch (err) {
      setError(String(err));
      setContent('');
    }
  }

  return (
    <div>
      <button onClick={loadFile}>Read example.txt from Documents</button>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {content && <pre>{content}</pre>}
    </div>
  );
}

export default App;

When the user clicks the button, the app constructs the full path by joining the user's Documents directory with the filename, then calls readTextFile. The Rust plugin checks the path against the scope $DOCUMENTS/** — since the resolved path falls inside Documents, the operation succeeds and the file content appears on screen. If you had not added the scope entry, the same call would throw a permission error. The error message from the plugin will include details about the denied path, which helps you debug missing scopes.

Common Pitfalls

  • Forgetting to add the plugin to lib.rs — The app compiles fine, but any file system call from JavaScript will throw an error about an unknown command or missing plugin. Always verify .plugin(tauri_plugin_fs::init()) is present.
  • Missing scope for absolute paths — Using baseDir: BaseDirectory.Home works for relative paths under that base directory, but passing an absolute path like "/home/user/file.txt" requires a fs:scope entry that covers that path. Many beginners confuse base directories with scopes; they serve different purposes.
  • Assuming "**/*" as a scope pattern works — The correct pattern is "**" appended to a directory prefix, like "$HOME/**". Using "**/*" alone is not a valid scope path because the plugin expects a rooted directory path before the glob. The correct minimal pattern for "allow everything" is "**", but you'd still need to attach it to a base variable like "$HOME/**" or explicitly allow the root with an absolute path "/**" (which is strongly discouraged).
  • Confusing the File System plugin with the Dialog plugin — The File System API provides low‑level read/write/delete operations but does not show file picker dialogs. To let users choose files visually, you need the Dialog plugin (@tauri-apps/plugin-dialog) alongside the fs plugin. The two are separate: Dialog opens the picker, fs reads the content.
  • Not handling errors from denied operations — A permission denial throws an exception that crashes your React component if uncaught. Always wrap file operations in try/catch and display a meaningful message.

The path traversal protection can surprise you:

Even seemingly harmless relative paths like "./../file.txt" are blocked outright, even if the final resolved path would still be inside the allowed scope. The check operates on the raw path string you pass, not the resolved canonical path. Use only clean, forward-path constructions — path.join() from the Path API is the safest approach.

Summary

The File System API is the bridge between your React UI and the user's actual disk. In Tauri v2, it comes as a plugin that you install on both the Rust and JavaScript sides, then activate through a capabilities file that defines exactly what directories your app can touch. This layered design — install plugin, configure scope, make calls — is not accidental complexity. It is the mechanism that lets your app behave like a native program while keeping the user's files safe from unintended access. Once you understand that every file operation must pass through a scope check, the permission system becomes predictable and easy to debug. You now have the plugin installed and configured. If you run into permission denials at any point, return to your capability file: the answer is almost always a missing or too-narrow scope.