Tauri Patterns for Production: Tauri 2 Plugins: fs · dialog · notification — Build a Real Editor in 100 Lines
Video: Tauri 2 Plugins: fs · dialog · notification — Build a Real Editor in 100 Lines | Ep3 by CelesteAI
Tauri 2 ships with a real plugin ecosystem. Most plugins are JS-callable APIs you register once on the builder; from then on, the frontend imports
@tauri-apps/plugin-*and calls functions like any npm package. We build Inkpad, a minimal text editor, using three official plugins:dialog(native file picker),fs(read/write disk),notification(system banners).
If episode 2 was the bridge, episode 3 is the toolbox. Almost every desktop-app feature you’d want — open a file, save a file, show a system notification, talk to an HTTP API, persist a key/value, store SQLite rows, auto-update — has an official Tauri plugin. Today we wire up three of them in one ~100-line app.
The official plugin landscape
You don’t memorise these — you discover them when you need one. The names are honest about what they do:
| Plugin | What it gives you | Typical use |
|---|---|---|
tauri-plugin-fs |
Read/write the user’s filesystem | Load/save documents, config, caches |
tauri-plugin-dialog |
Native open/save/message dialogs | “Pick a file” — the system picker |
tauri-plugin-notification |
OS-level notification banners | “Saved”, “Build done”, background alerts |
tauri-plugin-http |
HTTP client (no CORS) | Talk to APIs without a browser sandbox |
tauri-plugin-sql |
SQLite/Postgres/MySQL | Local-first persistent storage |
tauri-plugin-store |
Key/value JSON store | App settings, “remember last opened file” |
tauri-plugin-shell |
Run shell commands, open URLs | xdg-open, custom CLIs |
tauri-plugin-os |
OS info — platform, arch, hostname | Branching behaviour by platform |
tauri-plugin-clipboard-manager |
Read/write the clipboard | Copy buttons, paste-from-app |
tauri-plugin-window-state |
Persist window size/position | “Open where I left it” |
tauri-plugin-process |
restart(), exit(code) |
Reload after update |
tauri-plugin-updater |
Self-update from a release URL | The killer feature for shipped apps |
Today’s three are the ones you’ll reach for first when you build anything with files.
Adding a plugin: one command, one line, one import
The tauri add <name> CLI does three things in one shot. For each plugin we want:
pnpm tauri add dialog
pnpm tauri add fs
pnpm tauri add notification
Each command:
- Adds the Cargo dependency (
tauri-plugin-<name>) tosrc-tauri/Cargo.toml. - Adds the npm package (
@tauri-apps/plugin-<name>) topackage.json. - Registers the plugin on the builder in
lib.rsand adds the plugin’s default permission tocapabilities/default.json.
After running those three, your lib.rs is essentially this:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Notice what’s not there: no custom #[tauri::command] functions, no state, no setup hook. For these three plugins, the frontend can call everything it needs through the JS API. Rust’s only job is to register them.
Capabilities — the permission gate
The other file tauri add touches is src-tauri/capabilities/default.json:
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"fs:default",
"notification:default"
]
}
A capability is the answer to “which windows can call which plugin APIs.” If the permission isn’t listed here, the plugin call from JS errors out at runtime. This is Tauri’s way of keeping plugins opt-in: shipping a plugin doesn’t automatically grant it; the capability has to declare it. For development, *:default covers the typical-safe subset of each plugin’s API. For production, you’d narrow to e.g. fs:read-text-file if that’s all the app actually needs.
What we’ll build: Inkpad
A bare-bones text editor:
- A textarea you can type in.
- Open — pick a .txt/.md file via the system picker, load its contents.
- Save — write the textarea back to the current file.
- Save As — pick a new path and save there.
- New — clear the buffer.
Every save fires both an in-app toast and an OS notification banner. The whole app is one React component plus a 9-line lib.rs.
The frontend — App.tsx
The imports tell the whole story:
import { open, save } from "@tauri-apps/plugin-dialog";
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
Three plugins, three imports. From here, every action is a JS function call.
Open: dialog + fs
const openFile = async () => {
const selected = await open({
multiple: false,
filters: [{ name: "Text", extensions: ["txt", "md"] }],
});
if (typeof selected !== "string") return;
const content = await readTextFile(selected);
setText(content);
setPath(selected);
};
open() shows the native file picker and returns the chosen path (or null if cancelled). readTextFile() reads the file as a string. We update React state with both the content and the path, so Save knows where to go.
Save: fs
const saveFile = async () => {
if (!path) return saveAs();
await writeTextFile(path, text);
await notify(`Saved ${path.split("/").pop()}`);
};
If we have a path, write directly. Otherwise, fall through to Save As, which prompts for one. Same writeTextFile either way — it’s the dialog plugin choosing the path that differs.
Save As: dialog (save) + fs
const saveAs = async () => {
const target = await save({
filters: [{ name: "Text", extensions: ["txt", "md"] }],
defaultPath: path ?? "untitled.txt",
});
if (!target) return;
await writeTextFile(target, text);
setPath(target);
await notify(`Saved ${target.split("/").pop()}`);
};
save() from the dialog plugin shows a native save panel. writeTextFile handles disk. The two plugins compose without ever talking to each other.
Notification: ask, then send
const notify = async (message: string) => {
let granted = await isPermissionGranted();
if (!granted) granted = (await requestPermission()) === "granted";
if (granted) await sendNotification({ title: "Inkpad", body: message });
};
Notifications need user permission once — the same model as the web Notifications API. isPermissionGranted checks; requestPermission prompts; sendNotification fires. After the first grant, the isPermissionGranted check returns true for all subsequent saves and the prompt never reappears.
Three plugin patterns to internalise
-
Most plugins are pure JS APIs.
tauri add <name>registers them on the Rust builder, then the entire surface area lives in@tauri-apps/plugin-<name>. You can build a complete file-editor without writing a single#[tauri::command]. Reach for Rust commands only when you need state, secrets, or platform APIs the JS plugin doesn’t expose. -
Plugins are gated by capabilities.
tauri addadds the plugin’s*:defaultpermission tocapabilities/default.json. If you ever see a runtime “permission denied” error from a plugin, it’s almost always that the capability isn’t listed. For production, narrow the permissions — e.g.fs:read-text-fileinstead offs:default— so a compromised webview can’t use the plugin’s full surface. -
Plugins compose without coordination. Inkpad uses three plugins that don’t know about each other: dialog returns a string, fs takes a string, notification takes a string. The composition lives entirely in your code. This is the strength of Tauri’s plugin model — each plugin has one job, one permission scope, one JS API.
What we didn’t cover (next stops)
plugin-sql: SQLite for local-first persistent state. Episode 4.plugin-store: a typed key/value store backed by JSON. Useful for “remember last open file” without spinning up SQL.plugin-http: HTTP client without browser CORS limits. Episode 5.plugin-updater: code-signed self-update from a remote release manifest. Episode 7.
Each of those follows the same pattern you saw today: tauri add, register on the builder, capability appears in default.json, import the JS API and call functions.
Recap
Tauri 2 plugins are the path of least resistance for any feature that touches the OS — files, dialogs, notifications, HTTP, SQL, the updater. The mental model is small:
tauri add <name>handles Cargo, npm, builder registration, and capability in one command.- Most plugins expose a JS API you import and call directly — no Rust glue code.
- Capabilities decide which APIs each window can hit; tighten them for production.
Inkpad uses three plugins in ~100 lines of TypeScript and 9 lines of Rust. The same pattern scales: add a plugin per OS feature you need, narrow the capability, call from JS. Episode 4 pushes further into local-first state with plugin-sql.
Generated by Claude AI · Tauri Patterns for Production · Codegiz