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

The Tauri file system plugin gives your React frontend safe, controlled access to files on the user’s computer. Reading a file is the most fundamental operation — whether it is loading a saved configuration, opening an image, or parsing a local CSV. This guide covers everything you need to know: the JavaScript functions, the permission model, error recovery, and the small but important differences between reading text, binary data, and structured formats like JSON.

How Reading Works Under the Hood

When your React code calls a function like readTextFile, it does not reach the hard drive directly. The call travels over Tauri’s IPC bridge to the Rust backend, where the tauri-plugin-fs crate uses the operating system’s native file APIs to open and read the requested path. The result then crosses back into JavaScript as a promise that resolves with the file’s content.

This design keeps the renderer process sandboxed. The frontend never touches the file system itself — it asks the backend to do it, and the backend only honours requests that fall within the capability scopes you have configured.

Prerequisites

You need the file system plugin installed and registered. If you followed the setup from the introduction, you are ready. If you are adding reading to a fresh project, use the step‑by‑step instructions below.

1

Step 1: Install the plugin

Use your package manager to add the plugin to both the Rust backend and the JavaScript frontend.

npm run tauri add fs

This single command updates Cargo.toml, src-tauri/src/lib.rs, and installs the npm package @tauri-apps/plugin-fs.

2

Step 2: Verify the Rust registration

Open src-tauri/src/lib.rs and confirm that the plugin is initialised. The tauri add command inserts the line automatically, but it is worth checking.

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init()) // this line must exist
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
3

Step 3: Create a capability file for file reading

By default, Tauri denies all file access. You must explicitly grant permission to read from specific directories. Create or edit a JSON file inside src-tauri/capabilities/.

{
  "identifier": "read-files",
  "description": "Capability to read user files",
  "windows": ["main"],
  "permissions": [
    {
      "identifier": "fs:scope",
      "allow": [
        { "path": "$APPDATA/**" },
        { "path": "$HOME/**" }
      ]
    }
  ]
}

The fs:scope permission controls which directories the plugin may touch. $APPDATA/** and $HOME/** are placeholders that Tauri resolves to the appropriate system paths at runtime. The ** wildcard grants access to all files and subdirectories recursively.

4

Step 4: Confirm the plugin is available in JavaScript

In any React component, import a function and log it to verify that the bindings load without error.

import { readTextFile } from "@tauri-apps/plugin-fs";
console.log(typeof readTextFile); // "function"

Setup Complete:

If you see "function" in the console and no build errors, the plugin is properly installed and wired up.

Reading a Text File

The simplest entry point is readTextFile. Give it a path and it returns a promise that resolves to a string.

Basic Example

Create a React component that reads a plain text file from the user’s home directory and displays the content.

import { useState } from "react";
import { readTextFile, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function TextReader() {
  const [content, setContent] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  async function loadFile() {
    try {
      const text = await readTextFile("documents/notes.txt", {
        baseDir: BaseDirectory.Home,
      });
      setContent(text);
      setError(null);
    } catch (e) {
      setError(String(e));
      setContent(null);
    }
  }
  return (
    <div>
      <button onClick={loadFile}>Load notes.txt</button>
      {error && <p style={{ color: "red" }}>{error}</p>}
      {content && <pre>{content}</pre>}
    </div>
  );
}

When the button is clicked, the plugin resolves the path ~/documents/notes.txt and returns the entire file as a UTF-8 string. If the file does not exist or the path lies outside the allowed scopes, the promise rejects and the error message appears in the UI.

Understanding BaseDirectory

The baseDir option pins the relative path to a well‑known system directory. Without it, the plugin would interpret the path relative to an unpredictable working directory — usually not what you want. The BaseDirectory enum provides a consistent anchor for every platform.

Common variants you will use:

VariantResolves to (example)
Home/home/alice / C:\Users\alice
AppDataApp‑specific data folder
AppConfigApp‑specific config folder
DesktopUser’s desktop
DocumentsUser’s documents folder

Always set a baseDir:

Leaving baseDir out means the plugin treats the path as absolute — but that absolute path must still be covered by an fs:scope allow entry for "/**" or a specific drive. For portability, baseDir is almost always the right choice.

Reading a Binary File

When you need raw bytes — for an image, a video snippet, or a binary data file — use readFile. It returns a Uint8Array, not a string.

Loading an Image and Displaying It

This component reads a PNG file and turns the byte array into a data URL that an <img> tag can render.

import { useState } from "react";
import { readFile, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function ImageViewer() {
  const [src, setSrc] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  async function loadImage() {
    try {
      const bytes = await readFile("pictures/photo.png", {
        baseDir: BaseDirectory.Home,
      });
      const blob = new Blob([bytes], { type: "image/png" });
      setSrc(URL.createObjectURL(blob));
      setError(null);
    } catch (e) {
      setError(String(e));
      setSrc(null);
    }
  }
  return (
    <div>
      <button onClick={loadImage}>Load photo.png</button>
      {error && <p style={{ color: "red" }}>{error}</p>}
      {src && <img src={src} alt="Loaded from disk" width={300} />}
    </div>
  );
}

The Uint8Array is wrapped in a Blob and then turned into an object URL. This pattern works for any binary file as long as the browser knows the MIME type.

Large files stay in memory:

readFile loads the entire file into the JavaScript heap at once. For files larger than a few hundred megabytes, prefer the file‑handle approach described later, which lets you read in chunks.

Reading a JSON Configuration File

A configuration file is just text — but after reading you must parse it with JSON.parse. The challenge is that both the file‑read and the JSON parse can fail, and each failure requires a different user message.

import { useState } from "react";
import { readTextFile, BaseDirectory } from "@tauri-apps/plugin-fs";
interface AppConfig {
  theme: string;
  fontSize: number;
}
export default function ConfigLoader() {
  const [config, setConfig] = useState<AppConfig | null>(null);
  const [error, setError] = useState<string | null>(null);
  async function loadConfig() {
    try {
      const raw = await readTextFile("config/settings.json", {
        baseDir: BaseDirectory.AppConfig,
      });
      const parsed: AppConfig = JSON.parse(raw);
      setConfig(parsed);
      setError(null);
    } catch (e) {
      if (e instanceof SyntaxError) {
        setError("settings.json contains invalid JSON. Check for trailing commas or missing quotes.");
      } else {
        setError(String(e));
      }
      setConfig(null);
    }
  }
  return (
    <div>
      <button onClick={loadConfig}>Load settings</button>
      {error && <p style={{ color: "red" }}>{error}</p>}
      {config && (
        <ul>
          <li>Theme: {config.theme}</li>
          <li>Font size: {config.fontSize}</li>
        </ul>
      )}
    </div>
  );
}

Catching SyntaxError separately gives you a chance to tell the user that the file itself exists, but its contents are malformed. That distinction is important in a production app — a missing file and a corrupt file are different problems.

Do not assume valid JSON:

If you call JSON.parse without a try‑catch specific to it, a syntax error will be caught by the outer catch, but the user may see a confusing error message about readTextFile when the real problem is a missing comma.

Using File Handles for Granular Control

readTextFile and readFile are convenient, but they give you no influence over how the file is opened. The open function returns a file handle that you can configure for read‑only access, and then read in sized chunks, check the file size beforehand, or combine reading with metadata queries.

Reading a Large File in Fixed-Size Chunks

This pattern is useful when you want to show progress or avoid a memory spike.

import { useState } from "react";
import { open, BaseDirectory } from "@tauri-apps/plugin-fs";
export default function ChunkedReader() {
  const [lines, setLines] = useState<string[]>([]);
  async function loadChunks() {
    const file = await open("logs/app.log", {
      read: true,
      baseDir: BaseDirectory.AppData,
    });
    const stat = await file.stat();
    const total = stat.size;
    let offset = 0;
    const chunkSize = 1024; // 1 KB chunks
    const decoder = new TextDecoder();
    const parts: string[] = [];
    while (offset < total) {
      const toRead = Math.min(chunkSize, total - offset);
      const buf = new Uint8Array(toRead);
      await file.read(buf);
      parts.push(decoder.decode(buf, { stream: true }));
      offset += toRead;
    }
    await file.close();
    const fullText = decoder.decode(); // final flush
    setLines(fullText.split("\n"));
  }
  return (
    <div>
      <button onClick={loadChunks}>Read log file</button>
      <pre>{lines.slice(0, 20).join("\n")}</pre>
    </div>
  );
}

The file.read(buf) method fills buf with the next bytes from the file, advancing the internal cursor. The TextDecoder with { stream: true } handles multi‑byte characters that may be split across chunk boundaries. Always call file.close() when you are finished to release the operating system file descriptor.

Unclosed handles block rewrites on Windows:

On Windows, an open file handle can prevent other processes — or even your own app — from writing to the same file until the handle is closed. Explicit close() calls prevent hard-to-debug lock errors.

Permission and Scope Deep Dive

The most common source of confusion when reading files is the permissions system. Tauri’s security model is deny‑by‑default. Even if you installed the plugin correctly, a call to readTextFile will fail with a “path not allowed on the configured scope” error unless the directory is explicitly listed in a capability file.

The fs:scope Permission

The fs:scope identifier controls which directories the plugin may access. Each entry in the allow array is an object with a path string. The path can contain placeholder variables and wildcards.

{
  "identifier": "read-files",
  "description": "Capability to read user documents and pictures",
  "windows": ["main"],
  "permissions": [
    {
      "identifier": "fs:scope",
      "allow": [
        { "path": "$DOCUMENT/**" },
        { "path": "$PICTURE/**" }
      ]
    }
  ]
}

Variable placeholders such as $DOCUMENT and $PICTURE are resolved to the platform‑specific user directories. The ** wildcard grants access to everything inside the folder — all files and all nested folders.

To allow reading from anywhere on the file system (useful for apps that let users open any file they choose), use the most permissive pattern:

{ "path": "**/*" }

The **/* scope is the most powerful:

Granting **/* gives the plugin read access to every file the operating system allows your app to touch. Use it only when your application genuinely needs universal access, and combine it with a user‑driven file picker so the user is in control.

Path Traversal is Blocked

The plugin automatically rejects paths that contain ../ segments or attempt to escape an allowed directory through symlink tricks. You cannot, for example, pass "../../etc/passwd" and expect it to resolve outside $HOME. The Rust side normalises the path and verifies it against the scope before ever calling the OS file API.

Reading Files Chosen by the User

A common workflow is to combine the dialog plugin with the file system plugin. The dialog shows a native file picker; once the user selects a file, you receive an absolute path and can then read it.

import { useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { readTextFile } from "@tauri-apps/plugin-fs";
export default function DialogReader() {
  const [content, setContent] = useState<string | null>(null);
  async function pickAndRead() {
    const selected = await open({
      multiple: false,
      filters: [{ name: "Text", extensions: ["txt", "md"] }],
    });
    if (selected) {
      const text = await readTextFile(selected);
      setContent(text);
    }
  }
  return (
    <div>
      <button onClick={pickAndRead}>Open a text file</button>
      {content && <pre>{content}</pre>}
    </div>
  );
}

Because open() returns an absolute path like /home/alice/some-file.txt, your capability file must include a scope that covers the directories the user might navigate to. A pattern like { "path": "**/*" } is often necessary for this workflow.

Error Handling Patterns

Reading files can fail for many reasons, and each deserves a different message.

Error scenarioTypical user message
Path not in scope“Access denied. The app does not have permission to read from this location.”
File not found“The file could not be found. It may have been moved or deleted.”
Insufficient OS permissions (macOS)“The operating system blocked the read. Check the app’s privacy settings.”
Corrupt or unreadable byte sequence“The file contains unexpected data and cannot be displayed.”

Wrap every read operation in a try‑catch, and avoid surfacing raw Rust error strings to the user. Translate the most frequent errors into human‑friendly alerts.

A clean error gives the user a next step:

Instead of printing a cryptic Os { code: 2, kind: NotFound }, a message like “The file notes.txt was not found in your Documents folder” tells the user what to check.

Platform-Specific Considerations

macOS

In development, your unsigned app may encounter PermissionDenied errors when reading absolute paths outside common user directories — even if the scope permits it. This happens because macOS sandboxing treats dev builds more strictly than production‑signed apps. To work around it during development, add a temporary exception entitlement:

<key>com.apple.security.temporary-exception.files.absolute-path.read-only</key>
<array>
    <string>/</string>
</array>

Reference this file in your tauri.conf.json under bundle > macOS > entitlements. For a production build, proper sandbox entitlements tied to user‑selected files via the powerbox are a better long‑term solution.

Windows

No additional configuration is required for reading files from user directories. Writing to protected areas like Program Files requires administrator privileges, but reading is unrestricted provided the scope allows it.

Linux

The plugin follows standard Unix file permissions. If the user running the app cannot read a file (lack of group or other permissions), the read will fail at the OS level. No Tauri‑specific workaround exists — the user must adjust file permissions manually.

Best Practices

  • Always use baseDir when reading files you know the location of. It keeps paths portable and reduces the chance of a scope mismatch.
  • Validate file existence early. Call exists() from the same plugin before attempting a heavy read, especially if the file comes from user input.
  • Close every file handle. If you used open(), pair it with a finally block or an explicit close() after the read loop.
  • Be mindful of encoding. readTextFile assumes UTF-8. If your file is Latin‑1 or Windows‑1252, read it as binary with readFile and use a TextDecoder with the correct encoding.
  • Use chunked reads for files over roughly 50 MB. It keeps the UI responsive and avoids a single giant allocation that could trigger an out‑of‑memory crash on constrained systems.
  • Test capability files on every target platform. A path like $HOME/Documents resolves differently on macOS, Windows, and Linux. Run your app on each OS at least once to confirm the scopes map where you expect.

Summary

Reading files in Tauri v2 is a collaboration between the JavaScript plugin, the Rust backend, and a capability file that you control. The readTextFile and readFile functions cover the most common cases with minimal code. When you need more control — progress reporting, partial reads, or inspection before loading — the handle returned by open gives you that power.

The single most important habit to build is treating permissions as part of the code, not an afterthought. Every new directory you intend to read from must appear in your capability file, and every read should be wrapped in error handling that speaks the user’s language. Once that pattern becomes second nature, reading any file from a React frontend feels as natural as fetching data from an API.