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.
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
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:
{
"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.
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.
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:
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.
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’tawaitthem, the file operation may still be in progress when you assume it’s done, and errors go uncaught. - Missing the
fs:allow-write-text-fileorfs:allow-write-binary-filepermission: 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:
writeTextFiledoes not create intermediate folders. If you write toappDataDir()/reports/summary.txtbut thereportsfolder doesn’t exist, the call fails. UsecreateDirfrom thefsplugin 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
existsbefore 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-fsbefore 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.