JSON in Tauri: Commands, Store Plugin, and Rust–Frontend Bridge
Last updated:
Tauri passes JSON between the Rust backend and the JavaScript frontend through its command system: the frontend calls invoke('command_name', { arg: value }), Tauri serializes the argument as JSON, deserializes it in Rust using serde_json, and returns a JSON-serializable value back. All Tauri command arguments and return values must implement serde::Serialize and serde::Deserialize — Tauri handles the serialization transparently. Define a command with #[tauri::command] and add it to tauri::Builder. On the frontend, invoke<ReturnType>('command_name', args) returns a typed Promise<ReturnType>. For JSON file persistence, tauri-plugin-store provides a key-value store backed by a JSON file in the app data directory — set and get values with store.set('key', value) and store.get<T>('key'). For configuration, Tauri reads tauri.conf.json at build time — the config is JSON-based. This guide covers Tauri command JSON serialization, serde_json in Rust, typed invoke() calls, tauri-plugin-store for JSON persistence, and reading/writing JSON files from Rust commands.
Tauri Commands: Passing JSON Arguments
Bottom line: #[tauri::command] turns any Rust function into an IPC endpoint. Arguments are deserialized from JSON automatically — if the frontend passes { name: "Widget", price: 9.99 }, Rust receives a typed struct with no manual parsing required.
Tauri's command system uses serde under the hood. Every argument type must implement serde::Deserialize and every return type must implement serde::Serialize. Derive both with #[derive(Serialize, Deserialize)] on your structs. By default, Tauri maps camelCase JavaScript keys to snake_case Rust fields — add #[serde(rename_all = "camelCase")] to the struct to make this mapping explicit. Register commands in main.rs with tauri::generate_handler! — the macro takes a comma-separated list of function names, and each must be annotated with #[tauri::command]. If a command returns Err(string), the corresponding invoke() Promise rejects with that string — catch it in a try/catch block or with .catch().
// src-tauri/src/commands.rs
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateProduct {
pub name: String,
pub price: f64,
pub category: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Product {
pub id: u32,
pub name: String,
pub price: f64,
pub category: String,
}
// #[tauri::command] marks this as an IPC endpoint
#[tauri::command]
pub fn create_product(payload: CreateProduct) -> Result<Product, String> {
// Validate price — must be positive
if payload.price <= 0.0 {
return Err("Price must be greater than 0".into());
}
// In a real app, persist to a database or file here
Ok(Product {
id: 42,
name: payload.name,
price: payload.price,
category: payload.category,
})
}
// src-tauri/src/main.rs
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![create_product])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}// src/lib/tauri-api.ts — TypeScript side
import { invoke } from '@tauri-apps/api/core'
interface CreateProductArgs {
name: string
price: number
category: string
}
interface Product {
id: number
name: string
price: number
category: string
}
// invoke<Product> — TypeScript knows the resolved type is Product
async function createProduct(args: CreateProductArgs): Promise<Product> {
try {
return await invoke<Product>('create_product', { payload: args })
} catch (err) {
// Rust Err(string) rejects the Promise with that string
throw new Error(typeof err === 'string' ? err : 'Unknown error')
}
}
// Usage
const product = await createProduct({ name: 'Widget', price: 9.99, category: 'tools' })
console.log(product.id, product.name) // fully typedTyped invoke() Calls in TypeScript/React
Bottom line: invoke<T>('command_name', args) returns Promise<T>. Without the generic, TypeScript infers unknown — always provide the type to get compile-time safety on every call site.
Define a TypeScript interface whose field names match the Rust struct's serialized form. If the Rust struct uses #[serde(rename_all = "camelCase")], the TypeScript interface uses camelCase — field names must match exactly or values silently appear as undefined. Keep all command wrappers in a single src/lib/tauri-api.ts file, and all Rust commands in src-tauri/src/commands.rs — this 1-to-1 structure makes schema drift immediately visible. A React hook wrapping invoke should handle loading, error, and data states consistently across the app. For commands that return large lists, consider streaming results using Tauri events rather than a single invoke response — the command system supports up to ~1 MB payloads by default.
// src/lib/tauri-api.ts — all typed command wrappers in one file
import { invoke } from '@tauri-apps/api/core'
// TypeScript interfaces mirror the Rust structs field-for-field
export interface Product {
id: number
name: string // matches Rust: pub name: String
price: number // matches Rust: pub price: f64
category: string
}
export interface CreateProductArgs {
name: string
price: number
category: string
}
// Typed wrappers — callers never touch invoke() directly
export const tauriApi = {
listProducts: () => invoke<Product[]>('list_products'),
createProduct: (payload: CreateProductArgs) =>
invoke<Product>('create_product', { payload }),
deleteProduct: (id: number) =>
invoke<void>('delete_product', { id }),
}
// src/hooks/use-invoke.ts — React hook for invoke calls
import { useState, useEffect } from 'react'
export function useInvoke<T>(
command: string,
args?: Record<string, unknown>
) {
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
invoke<T>(command, args)
.then(setData)
.catch((err) => setError(typeof err === 'string' ? err : 'Command failed'))
.finally(() => setLoading(false))
// args must be stable (memoized) to avoid infinite loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [command])
return { data, error, loading }
}
// Usage in a component
function ProductList() {
const { data: products, error, loading } = useInvoke<Product[]>('list_products')
if (loading) return <p>Loading...</p>
if (error) return <p>Error: ${error}</p>
return (
<ul>
${products?.map((p) => (
<li key=${p.id}>${p.name} — ${p.price.toFixed(2)}</li>
))}
</ul>
)
}serde_json for Dynamic JSON in Rust
Bottom line: when the JSON structure is not known at compile time, use serde_json::Value as the parameter or return type. It accepts any valid JSON and lets you navigate the tree at runtime with accessor methods.
serde_json::Value is an enum with 6 variants matching the 6 JSON types: Null, Bool, Number, String, Array, and Object. Access nested fields with index syntax: data["key"] returns a &Value, and data["key"].as_str() returns Option<&str>. The json! macro builds a Value from a JSON literal with zero boilerplate — it's available after adding serde_json to Cargo.toml. Convert between Value and typed structs with serde_json::to_value() and serde_json::from_value() — both return Result. Prefer typed structs over Value for any command with a stable schema: compile-time checks catch field name typos that Value silently returns as Value::Null.
// Cargo.toml
// [dependencies]
// serde_json = "1"
// serde = { version = "1", features = ["derive"] }
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::fs;
// --- Dynamic JSON command: accepts any JSON structure ---
#[tauri::command]
fn process_json(data: Value) -> Value {
// Access fields safely — returns None if missing or wrong type
let name = data["name"].as_str().unwrap_or("unknown");
let count = data["count"].as_u64().unwrap_or(0);
// Build a response with the json! macro
json!({
"status": "ok",
"processed": name,
"count": count,
"doubled": count * 2
})
}
// --- Convert struct to Value and back ---
#[derive(Serialize, Deserialize)]
struct Config {
theme: String,
font_size: u32,
}
fn struct_to_value_example() -> Result<(), serde_json::Error> {
let cfg = Config { theme: "dark".into(), font_size: 14 };
// Struct → Value (for dynamic manipulation)
let mut val: Value = serde_json::to_value(&cfg)?;
val["font_size"] = json!(16); // modify dynamically
// Value → Struct (back to typed)
let updated: Config = serde_json::from_value(val)?;
println!("Updated font size: {}", updated.font_size); // 16
Ok(())
}
// --- Read JSON file from disk ---
#[tauri::command]
fn read_json_file(path: String) -> Result<Value, String> {
let content = fs::read_to_string(&path)
.map_err(|e| format!("Read error: {e}"))?;
serde_json::from_str(&content)
.map_err(|e| format!("Parse error: {e}"))
}
// --- Write JSON file to disk ---
#[tauri::command]
fn write_json_file(path: String, data: Value) -> Result<(), String> {
// to_string_pretty adds 2-space indentation
let content = serde_json::to_string_pretty(&data)
.map_err(|e| format!("Serialize error: {e}"))?;
fs::write(&path, content)
.map_err(|e| format!("Write error: {e}"))
}JSON Persistence with tauri-plugin-store
Bottom line: tauri-plugin-store stores key-value pairs in a JSON file on disk. Call store.set('key', value) and store.get<T>('key') from the frontend — no Rust code needed for basic persistence.
The store JSON file is saved to the platform's app data directory: ~/.config/app-name/ on Linux, %APPDATA%\app-name\ on Windows, and ~/Library/Application Support/app-name/ on macOS. The file name you pass to load() (e.g., 'settings.json') becomes the file name on disk. With autoSave: false (the default), changes are in-memory only until you call store.save() — call it after every write batch or on app exit. With autoSave: true, save() is called after every set() — convenient but adds 1 disk write per key change. The store accepts any JSON-serializable value: strings, numbers, booleans, arrays, and objects. Use it for user preferences, window state, and recently opened files — anything that must survive app restarts.
# Terminal: install the plugin
cargo add tauri-plugin-store
npm install @tauri-apps/plugin-store// src-tauri/src/main.rs — register the plugin
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
.invoke_handler(tauri::generate_handler![])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}// src/lib/store.ts — frontend store usage
import { load } from '@tauri-apps/plugin-store'
interface Preferences {
theme: 'light' | 'dark'
fontSize: number
recentFiles: string[]
}
// load() opens the store (creates the file if it doesn't exist)
// autoSave: false — you control when to flush to disk
const store = await load('settings.json', { autoSave: false })
// --- Set values (any JSON-serializable type) ---
await store.set('theme', 'dark')
await store.set('fontSize', 14)
await store.set('recentFiles', ['project.json', 'config.json'])
// --- Get values — generic T for type safety ---
const theme = await store.get<string>('theme') // 'dark'
const size = await store.get<number>('fontSize') // 14
const files = await store.get<string[]>('recentFiles') // ['project.json', ...]
// --- Flush to disk (writes the JSON file) ---
await store.save()
// --- List all stored keys ---
const keys = await store.keys()
console.log(keys) // ['theme', 'fontSize', 'recentFiles']
// --- Delete a single key ---
await store.delete('fontSize')
// --- Reset the entire store ---
await store.clear()
await store.save() // persist the cleared state
// --- Store structured preferences object ---
const prefs: Preferences = {
theme: 'dark',
fontSize: 14,
recentFiles: ['project.json'],
}
await store.set('preferences', prefs)
const saved = await store.get<Preferences>('preferences')
console.log(saved?.theme) // 'dark'Reading and Writing JSON Files from Rust Commands
Bottom line: write a Rust command that accepts a tauri::AppHandle, resolves the correct platform directory via app.path(), then reads or writes the JSON file with std::fs. Grant file permissions in tauri.conf.json — Tauri v2 denies file access by default.
Tauri v2 restricts file system access through its security model — all file operations from the frontend must go through a declared Rust command, and the command must only access paths resolved through app.path(). This prevents path traversal attacks. Use app.path().app_config_dir() for configuration files, app.path().app_data_dir() for user data, and app.path().resource_dir() for bundled read-only assets. The config dir on macOS is ~/Library/Application Support/app-name/, on Linux ~/.config/app-name/, and on Windows %APPDATA%\app-name\. Always use serde_json::to_string_pretty when writing human-edited config files — the 2-space indentation makes diffs readable in version control.
// src-tauri/src/commands.rs
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use tauri::Manager; // for app.path()
#[derive(Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AppConfig {
pub theme: String,
pub language: String,
pub auto_save: bool,
}
// Read the config JSON file from the app config directory
#[tauri::command]
pub fn read_config(app: tauri::AppHandle) -> Result<AppConfig, String> {
let config_dir = app.path().app_config_dir()
.map_err(|e| format!("Failed to resolve config dir: {e}"))?;
let path = config_dir.join("config.json");
if !path.exists() {
// Return defaults if file doesn't exist yet
return Ok(AppConfig::default());
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("Read error: {e}"))?;
serde_json::from_str(&content)
.map_err(|e| format!("Parse error: {e}"))
}
// Write the config JSON file — creates the directory if needed
#[tauri::command]
pub fn write_config(app: tauri::AppHandle, config: AppConfig) -> Result<(), String> {
let config_dir = app.path().app_config_dir()
.map_err(|e| format!("Failed to resolve config dir: {e}"))?;
// Create the directory if it doesn't exist
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Dir create error: {e}"))?;
let path = config_dir.join("config.json");
let content = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Serialize error: {e}"))?;
fs::write(&path, content)
.map_err(|e| format!("Write error: {e}"))
}
// Read a bundled resource JSON file (read-only, shipped with the app)
#[tauri::command]
pub fn read_bundled_data(app: tauri::AppHandle) -> Result<Value, String> {
let resource_dir = app.path().resource_dir()
.map_err(|e| format!("Failed to resolve resource dir: {e}"))?;
let path = resource_dir.join("data.json");
let content = fs::read_to_string(&path)
.map_err(|e| format!("Read error: {e}"))?;
serde_json::from_str(&content)
.map_err(|e| format!("Parse error: {e}"))
}// tauri.conf.json — grant file access permissions (Tauri v2)
{
"$schema": "https://schema.tauri.app/config/2.json",
"productName": "my-app",
"version": "1.0.0",
"app": {
"windows": [{ "title": "My App", "width": 1024, "height": 768 }],
"security": {
"capabilities": [
{
"identifier": "default",
"permissions": [
"core:path:default",
"core:fs:allow-app-data-read-recursive",
"core:fs:allow-app-data-write-recursive",
"core:fs:allow-resource-read-recursive"
]
}
]
}
},
"bundle": {
"resources": ["data.json"]
}
}// src/lib/tauri-api.ts — TypeScript side
import { invoke } from '@tauri-apps/api/core'
interface AppConfig {
theme: string
language: string
autoSave: boolean
}
export async function readConfig(): Promise<AppConfig> {
return invoke<AppConfig>('read_config')
}
export async function writeConfig(config: AppConfig): Promise<void> {
return invoke<void>('write_config', { config })
}
// Usage
const config = await readConfig()
config.theme = 'dark'
await writeConfig(config)Configuration via tauri.conf.json
Bottom line: tauri.conf.json controls all build and runtime behavior for a Tauri app. It is read at build time by the Tauri CLI — changes require a rebuild, not a hot-reload.
The file lives at src-tauri/tauri.conf.json. The 4 most-used top-level fields are: productName (display name, used in installer and title bar), version (SemVer string, must match Cargo.toml), build.frontendDist (path to the compiled frontend, e.g., "../dist"), and app.windows (window configuration array). Thebundle.resources field lists files to ship alongside the binary — add "data.json" here to include a static JSON dataset the app reads at runtime from resource_dir(). For environment overrides, create tauri.conf.dev.json with dev-specific values (e.g., a different dev server URL) and tauri.conf.release.json with release values — the Tauri CLI merges them automatically based on the build mode. VS Code validates the file against the official JSON Schema at https://schema.tauri.app/config/2.json when the $schema key is present — this catches typos in permission identifiers before the next build.
// src-tauri/tauri.conf.json — annotated example
{
"$schema": "https://schema.tauri.app/config/2.json",
// App identity
"productName": "JsonEditor",
"version": "1.2.0",
"build": {
// Path to the compiled frontend (relative to src-tauri/)
"frontendDist": "../dist",
// Dev server URL during tauri dev
"devUrl": "http://localhost:5173"
},
"app": {
"windows": [
{
"title": "JSON Editor",
"width": 1200,
"height": 800,
"resizable": true,
"center": true
}
],
"security": {
"capabilities": [
{
"identifier": "default",
"permissions": [
"core:path:default",
"core:fs:allow-app-data-read-recursive",
"core:fs:allow-app-data-write-recursive",
"core:fs:allow-resource-read-recursive"
]
}
]
}
},
"bundle": {
// Files shipped alongside the binary — accessible via resource_dir()
"resources": [
"data/defaults.json",
"data/templates.json"
],
"targets": "all",
"identifier": "io.jsonic.jsoneditor",
"icon": ["icons/32x32.png", "icons/128x128.png"]
}
}// src-tauri/tauri.conf.dev.json — merged during "tauri dev"
// Only overrides differ from tauri.conf.json
{
"build": {
"devUrl": "http://localhost:5173"
},
"app": {
"windows": [
{
"title": "JSON Editor [DEV]"
}
]
}
}
// src-tauri/tauri.conf.release.json — merged for "tauri build"
{
"bundle": {
"active": true
}
}FAQ
How do I pass JSON data from JavaScript to a Rust Tauri command?
Call invoke('command_name', { argName: value }) from the frontend. Tauri serializes the second argument object to JSON. On the Rust side, define a struct with #[derive(Deserialize)] and use it as the function parameter type, annotated with #[tauri::command]. Field names must match — use #[serde(rename_all = "camelCase")] on the struct to align Rust snake_case with JavaScript camelCase. Register the command with tauri::generate_handler![your_command]. If the Rust function returns Err(string), the invoke() Promise rejects with that string.
How do I return typed JSON from a Tauri command to the frontend?
Add #[derive(Serialize)] to the Rust return struct. Tauri serializes it to JSON automatically. On the TypeScript side, use invoke<Product>('get_product', { id: 1 }) — the generic parameter types the resolved Promise value. Define a TypeScript interface matching the Rust struct's serialized form (camelCase if #[serde(rename_all = "camelCase")] is set). For fallible commands, return Result<Product, String> from Rust: success resolves the Promise, and Err(msg) rejects it. Always catch errors with try/catch.
How do I persist JSON data in a Tauri app?
Use tauri-plugin-store. Install with cargo add tauri-plugin-store and npm install @tauri-apps/plugin-store. Register the plugin in main.rs and load a store on the frontend: const store = await load('settings.json', { autoSave: false }). Write with store.set('key', value) and read with store.get<T>('key'). Call store.save() to flush to disk. The file is written to the platform's app data directory. Set autoSave: true to persist after every set() call — adds 1 disk write per change. Alternatively, write custom Rust commands that read and write JSON files using app.path().app_config_dir().
How do I use serde_json::Value for dynamic JSON in Tauri?
Use serde_json::Value as the parameter or return type in a command: fn process(data: Value) -> Value. Access fields with data["key"].as_str() (returns Option<&str>). Build a Value with the json! macro: json!({ "status": "ok", "count": 42 }). Convert a typed struct to Value with serde_json::to_value(&my_struct)? and back with serde_json::from_value::<MyStruct>(value)?. Read a JSON file from disk with fs::read_to_string(path)? then serde_json::from_str(&content)?. Prefer typed structs for any command with a stable schema — Value silently returns Null for missing keys.
How do I read and write JSON files from a Tauri app?
Write a Rust command that takes tauri::AppHandle and resolves the path using app.path().app_config_dir(). Read with fs::read_to_string(path).map_err(|e| e.to_string())? and parse with serde_json::from_str(&content).map_err(|e| e.to_string())?. Write with serde_json::to_string_pretty(&data)? and fs::write(path, content)?. In Tauri v2, add "core:path:default" and "core:fs:allow-app-data-read-recursive" to the permissions in tauri.conf.json. From TypeScript, call await invoke<Config>('read_config').
What is tauri.conf.json and how does it affect JSON handling?
tauri.conf.json is the central build and runtime config file read by the Tauri CLI at build time. It controls productName, version, build.frontendDist, and window settings. File system access is granted here — in Tauri v2, the permissions array inside app.security.capabilities lists exactly which paths the app can read and write. The bundle.resources field lists JSON data files shipped with the binary, accessible at runtime from app.path().resource_dir(). Use tauri.conf.dev.json and tauri.conf.release.json for environment overrides. Add "$schema": "https://schema.tauri.app/config/2.json" to enable VS Code validation.
Working with JSON in other desktop frameworks?
The same JSON serialization patterns — typed structs, dynamic values, file persistence — apply across desktop runtimes. See related guides for JSON in Rust, JSON configuration files, and JSON API design.
Open JSON ValidatorFurther reading and primary sources
- Tauri Commands documentation — Official Tauri guide for defining Rust commands with #[tauri::command], registering handlers, and calling them from the frontend with invoke()
- tauri-plugin-store — Official tauri-plugin-store repository with installation, API reference, and examples for persisting JSON key-value data to disk
- serde_json crate documentation — Official serde_json docs covering the Value enum, json! macro, to_value/from_value conversions, and reading/writing JSON from files
- Tauri v2 Security Capabilities — Official Tauri v2 guide for configuring file system access permissions and capability identifiers in tauri.conf.json
- tauri.conf.json schema reference — Complete reference for all tauri.conf.json fields including build, app, bundle, and security configuration with JSON schema validation