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.

Desktop applications need to store data on disk—user preferences, documents, logs, and settings. In a web browser, the File System Access API is limited and sandboxed. Tauri removes that limit, giving your app full access to the file system through a secure permission model. This guide covers writing files using the @tauri-apps/plugin-fs plugin: creating new files, overwriting existing content, appending data, and saving structured formats like JSON.

How Writing Files Works in Tauri v2

Tauri’s file system plugin exposes JavaScript functions that communicate with Rust back‑end commands over IPC. When you call writeTextFile or writeBinaryFile from your React frontend, the plugin serialises the request and sends it to the Rust side. The Rust code performs the actual file‑system call using the operating system’s APIs, then returns a result. This architecture keeps the main thread free and ensures that the webview never directly touches the disk—only the trusted Rust core does.

For a beginner, the mental model is: you ask Tauri’s “file helper” to write some data to a location, and it handles the messy system‑level work. You never need to worry about file descriptors, buffers, or raw POSIX calls.

Setting Up Permissions for Writing Files

Before any write call succeeds, Tauri’s security model demands explicit permission. The fs plugin won’t work unless your app’s capability file lists the exact operations you intend to use. The following steps install the plugin and grant the required permissions.

1

Step 1: Install the file system plugin

Run the following command in your Tauri project root to add the plugin to your frontend dependencies:

npm install @tauri-apps/plugin-fs

If you plan to build reliable file paths with the app data directory, install the Path API bindings as well:

npm install @tauri-apps/plugin-path
2

Step 2: Configure the capability file

Open src-tauri/capabilities/default.json (create the file if it doesn’t exist) and add the write‑related permissions. At minimum you need:

src-tauri/capabilities/default.json
{
  "identifier": "default",
  "description": "Default capability for the main window",
  "windows": ["main"],
  "permissions": [
    "fs:allow-write-text-file",
    "fs:allow-write-binary-file",
    "fs:allow-exists",
    "path:default"
  ]
}

fs:allow-write-text-file permits calling writeTextFile. fs:allow-write-binary-file is required for writeBinaryFile. fs:allow-exists lets you check whether a file already exists before you overwrite it. The path:default permission allows the path plugin to resolve trusted directories like the app data folder.

Missing permissions break writes silently:

Omitting a permission string causes the plugin call to throw an error at runtime. The error message may mention “operation not permitted” or “permission denied”—it will not remind you to add the specific string. Always verify your capability file when a write fails.

Writing a Text File (Overwrite by Default)

writeTextFile is the primary function for saving textual content. By default it creates a file if it doesn’t exist and overwrites any existing file at the target path. The options parameter lets you change that behaviour—most importantly with an append flag.

The following component writes whatever the user types into a <textarea> to a file named my-note.txt inside the app’s data directory. The file is replaced on every save.

src/components/NoteSaver.tsx
import { useState } from "react";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { appDataDir, join } from "@tauri-apps/plugin-path";
function NoteSaver() {
  const [note, setNote] = useState("");
  const [status, setStatus] = useState("");
  const saveNote = async () => {
    try {
      const appDir = await appDataDir();
      const filePath = await join(appDir, "my-note.txt");
      await writeTextFile(filePath, note);
      setStatus("Note saved successfully!");
    } catch (error) {
      setStatus(`Error saving note: ${error}`);
    }
  };
  return (
    <div>
      <textarea
        value={note}
        onChange={(e) => setNote(e.target.value)}
        placeholder="Write your note here…"
      />
      <button onClick={saveNote}>Save Note</button>
      <p>{status}</p>
    </div>
  );
}
export default NoteSaver;

The appDataDir() call returns the application’s data folder (~/.local/share/your-app on Linux, ~/Library/Application Support/your-app on macOS, C:\Users\name\AppData\Roaming\your-app on Windows). This is the recommended location for per‑user data—it’s writable, doesn’t require administrator privileges, and survives app updates.

writeTextFile expects a complete absolute path and a string. Any non‑string value must be serialised to text first (see the JSON section later). The function returns a Promise<void>, so you must await it or handle the promise chain. If the directory doesn’t exist, the call fails; use createDir from the fs plugin beforehand if you need to build the folder structure on the fly.

Overwriting happens without confirmation:

The default behaviour destroys the previous file content. If you need to keep old data—for example, in a log file—always pass {{ append: true }} as the third argument, or check the file’s existence first with the exists function.

Confirm the file was written:

After clicking Save Note, open your application’s data directory manually and look for my-note.txt. If the file contains the text you typed, the write succeeded. The success message in the UI is just the first verification; the real proof is the file on disk.

Appending Content to an Existing File

Adding data to the end of a file without disturbing existing content is a common pattern for logs, event streams, and cumulative reports. Set the append option to true inside the options object.

The following example appends a timestamp to a log file each time the user clicks a button. The file grows continuously—no data is ever overwritten.

src/components/LogButton.tsx
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { appDataDir, join } from "@tauri-apps/plugin-path";
function LogButton() {
  const logClick = async () => {
    try {
      const appDir = await appDataDir();
      const logPath = await join(appDir, "app.log");
      const entry = `${new Date().toISOString()} – Button clicked\n`;
      await writeTextFile(logPath, entry, { append: true });
    } catch (error) {
      console.error("Failed to write log entry:", error);
    }
  };
  return <button onClick={logClick}>Log Click</button>;
}
export default LogButton;

The third argument { append: true } tells Tauri to open the file in append mode. If the file does not exist, it is created first (just like with the default overwrite mode). The operating system guarantees that the new data is written after the existing bytes, even if another process is reading the file.

Writing Binary Data

Binary files (images, PDFs, executables, custom binary formats) require writeBinaryFile. Instead of a string, you pass a Uint8Array. This gives you raw byte‑level control over the file content.

A simple demonstration is converting a string to a UTF‑8 byte array and writing it as a binary file:

src/components/BinarySaver.tsx
import { writeBinaryFile } from "@tauri-apps/plugin-fs";
import { appDataDir, join } from "@tauri-apps/plugin-path";
function BinarySaver() {
  const saveBinary = async () => {
    const content = "Binary Hello";
    const encoder = new TextEncoder();
    const data = encoder.encode(content); // Uint8Array
    const appDir = await appDataDir();
    const filePath = await join(appDir, "hello.bin");
    await writeBinaryFile(filePath, data);
  };
  return <button onClick={saveBinary}>Save Binary File</button>;
}
export default BinarySaver;

In a real application, the Uint8Array would come from a <canvas> element, a file input, or a network response. The principle is identical: get the bytes, decide on a path, and call writeBinaryFile. Like its text counterpart, this function creates the file if it doesn’t exist and overwrites it unless you pass { append: true }.

Text vs binary encoding:

writeTextFile always writes a string using UTF‑8 encoding and is the right choice for human‑readable files, JSON, XML, or CSV. writeBinaryFile writes the raw bytes you provide, exactly as they are. If you write a Uint8Array containing a PNG, the result is a valid PNG file.

Saving JSON Files

Configuration objects, user preferences, and structured data often need to persist as JSON. You can combine JSON.stringify with writeTextFile to store any JavaScript object. The next component saves a simple user‑settings object to settings.json in the app data directory.

src/components/SettingsSaver.tsx
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { appDataDir, join } from "@tauri-apps/plugin-path";
interface Settings {
  theme: "light" | "dark";
  fontSize: number;
}
function SettingsSaver() {
  const saveSettings = async () => {
    const settings: Settings = {
      theme: "dark",
      fontSize: 14,
    };
    const json = JSON.stringify(settings, null, 2);
    const appDir = await appDataDir();
    const filePath = await join(appDir, "settings.json");
    await writeTextFile(filePath, json);
  };
  return <button onClick={saveSettings}>Save Settings</button>;
}
export default SettingsSaver;

The JSON.stringify call with null, 2 produces pretty‑printed JSON. The resulting file is a valid UTF‑8 text file, readable by any text editor or script. When the app restarts, you could read the file back with readTextFile and parse it with JSON.parse to restore the settings.

Circular structures cause silent failures:

JSON.stringify throws a TypeError if the object contains circular references. If you see a write operation never completing and an unhandled error in the console, check that your data is fully serialisable before passing it to writeTextFile.

Error Handling and Common Pitfalls

Writing files can fail for many reasons: the disk is full, the path is invalid, the parent directory doesn’t exist, the user revoked permissions, or the capability file is missing the right permission string. Every write call must be wrapped in a try/catch block, and the error must be shown to the user (or at least logged).

Common mistakes that cause write failures:

  • Using a relative path: The webview’s current working directory is unpredictable and may point to the app’s install folder, which is usually read‑only. Always build paths from appDataDir(), appLocalDataDir(), or another directory obtained from the path plugin.
  • Forgetting to await: The write functions return a promise. If you don’t await them, the file operation may still be in progress when you assume it’s done, and errors go uncaught.
  • Missing the fs:allow-write-text-file or fs:allow-write-binary-file permission: The error message will say “operation not permitted” or “permission denied”; it won’t hint at which capability string is missing.
  • Assuming directories already exist: writeTextFile does not create intermediate folders. If you write to appDataDir()/reports/summary.txt but the reports folder doesn’t exist, the call fails. Use createDir from the fs plugin to build the folder structure first.
  • Overwriting important files by accident: The default mode silently replaces existing content. If you’re writing a user‑created document, confirm the path is unique or check with exists before writing.

Unhandled write errors can lose data:

If a write fails and you don’t catch the error, the user may believe the data was saved when it wasn’t. Always display an error message and, if possible, give the user a way to retry the operation.

Best Practices

  • Use the application data directory: Store user‑generated files inside appDataDir() (or a subfolder) to keep them separate from the app binary and to follow each platform’s conventions.
  • Handle both text and binary consistently: Decide early whether the file is human‑readable or binary and stick to the appropriate write function. Mixing them can lead to encoding surprises.
  • Avoid frequent, small writes in tight loops: Group write operations or batch data before writing to reduce disk I/O overhead. For logs, append mode already minimises this impact.
  • Check file existence when avoiding overwrites is critical: Use exists(path) from the @tauri-apps/plugin-fs before writing, or let the user choose a new file name via the Save Dialog (covered in the Dialog API section).
  • Test on all target platforms: File paths, permission models, and system directories differ between Windows, macOS, and Linux. A path that works in development may fail on another OS if not built with join() and the path plugin’s base directories.

Summary

Writing files in Tauri v2 is a straightforward task once the permission model is understood and the right plugin functions are used. The writeTextFile function handles text, JSON, and any string‑serialisable content, while writeBinaryFile gives you raw byte control for images, PDFs, and binary formats. The append option turns an overwriting write into a cumulative one, covering everything from saving a single note to building a persistent log.

The single most important insight from this section is that file writing in Tauri is always explicit and permission‑gated. You must declare every operation your app needs in the capability file. This makes the app more secure and gives you a clear map of what your code is allowed to do on the user’s disk.