Tauri Patterns for Production: Tauri 2.0 Project Setup for Production: Capabilities, Plugins, the Real Structure
Video: Tauri 2.0 Project Setup for Production: Capabilities, Plugins, the Real Structure | Ep1 by CelesteAI
Tauri 2.0 changed the foundation. The 1.x allowlist is gone — replaced by capabilities, JSON files that grant exact permissions per window. Plugins are now first-class — most platform features ship as opt-in plugins instead of being built-in. This is the project structure every production Tauri 2 app starts from.
If you’ve kicked the tires on Tauri before — followed the official tutorial, run pnpm create tauri-app, watched the dev server start — you’ve seen the surface. This series is about everything past that point: the patterns that make a Tauri app survive a real release. Episode 1 maps the foundation.
Why Tauri 2.0 is a meaningful change
Tauri 1.x’s permission model was a single global allowlist in tauri.conf.json that listed every API surface your app was allowed to touch. It worked, but it was coarse. Every window in your app got the same permissions. Adding a feature meant adding it to a flat list. Auditing what an app could actually do meant reading one giant section of one config file and hoping nothing was missed.
Tauri 2.0 throws that out. Permissions are now capabilities — separate JSON files under src-tauri/capabilities/ that name specific permission sets and target specific windows. The default file ships with core:default (a baseline set) plus whatever opt-in plugins you’ve added. It’s more verbose but vastly more secure. Every API surface is named. Every window’s reach is explicit. There’s no quiet escape hatch.
The other shift: plugins are first-class. Tauri 2’s core (tauri = "2" in Cargo.toml) is intentionally minimal — just the runtime, the IPC bridge, and the window machinery. Everything else is an opt-in plugin: file system, HTTP, dialogs, OS info, notifications, the updater, SQL. You install the ones you need. Each one has its own JS API, its own Rust crate, and its own capability permissions. The bundle stays small because you didn’t pull in code for features you’re not using.
The two-half project layout
Open a fresh Tauri project and you’ll see two top-level directories:
stock-watcher/
├── package.json
├── pnpm-lock.yaml
├── tsconfig.json
├── vite.config.ts
├── index.html
├── src/ ← React frontend
│ ├── App.tsx
│ ├── App.css
│ ├── main.tsx
│ └── assets/
└── src-tauri/ ← Rust backend
├── Cargo.toml
├── tauri.conf.json
├── build.rs
├── capabilities/
│ └── default.json
└── src/
├── main.rs
└── lib.rs
src/ is the React frontend (TypeScript, Vite). src-tauri/ is the Rust backend (Cargo). Tauri keeps them strictly separated — different languages, different toolchains, different package managers. The boundary is a feature, not an inconvenience.
You don’t import Rust crates from the frontend, and you don’t import npm packages from Rust. The bridge between them is invoke() on the JavaScript side and #[tauri::command] on the Rust side. Everything else is one half or the other.
Three commands to a running project
The setup walkthrough in the video is three commands, in this order:
# 1. Scaffold
pnpm create tauri-app stock-watcher --template react-ts
# 2. Install
pnpm install
# 3. Verify
pnpm tauri --version
# → tauri-cli 2.1.0
pnpm create tauri-app is the official scaffolder. Pick the React TypeScript template and you’ll get the layout above with both halves wired up — a working pnpm tauri dev from the very first run. pnpm install pulls down the JavaScript dependencies (@tauri-apps/api, @tauri-apps/cli, React, Vite, TypeScript). pnpm tauri --version confirms you’re on the 2.x series — important because the rest of this series assumes 2.x semantics throughout.
A note on the version pinning. Your package.json will have @tauri-apps/api and @tauri-apps/cli at ^2. Your Cargo.toml will have tauri = "2" and any plugins as tauri-plugin-X = "2". Keep these in sync — mismatched JS and Rust versions of the same plugin are the usual cause of “works in dev but not in build” weirdness later on.
tauri.conf.json: window, identifier, bundle
The single most important config file in a Tauri project. Three sections matter for episode 1:
Identifier:
"identifier": "com.codegiz.stockwatcher"
This is the app’s unique ID across operating systems. macOS uses it for the bundle ID. Windows uses it for the installer registry key. Linux uses it for the .desktop file. Pick a reverse-domain identifier for any app you intend to ship — not the default com.tauri.dev, which Tauri will refuse to bundle in release mode.
Window config:
"app": {
"windows": [{
"title": "Stock Watcher · Codegiz",
"width": 1600,
"height": 900,
"minWidth": 900,
"minHeight": 600,
"decorations": true,
"resizable": true
}]
}
Title, dimensions, minimums, decorations, resizability. We’ve fixed the demo app’s window at a recording-friendly 1600×900 placed at (160, 90), but the typical production config sets center: true and lets the OS place the window.
Bundle:
"bundle": {
"active": true,
"targets": "all"
}
"all" means every platform’s native installer: .app + .dmg on macOS, .msi on Windows, .deb + .AppImage on Linux. Episode 8 of this series goes deep on bundling, signing, and notarization. For now, just know that this is where it’s controlled.
Capabilities: the new permission model
This is the single biggest change from 1.x. Open src-tauri/capabilities/default.json:
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
A capability is a JSON file with a name (identifier), a description, the windows it applies to, and a list of permissions. core:default is a baseline set Tauri provides. opener:default comes from the tauri-plugin-opener we’ve installed. Each plugin you install brings its own permission set, and you opt into them per-capability, per-window.
Why this matters in production: - Auditability: every API surface a window can touch is in a JSON file. CI can lint these. Reviewers can grep them. - Per-window scoping: a settings window can have permissions a main window doesn’t, and vice versa. - No accidental escape: missing a permission is a build-time error, not a runtime crash in the field.
Adding a plugin is a two-step thing. You add the crate to Cargo.toml, you register it in lib.rs with .plugin(...), and then you add its permission identifier to whichever capability JSON should grant it. Skip the third step and the plugin’s API will compile but throw at runtime.
The Rust entry point
src-tauri/src/lib.rs is the heart of the Rust side:
use rand::Rng;
use serde::Serialize;
#[derive(Serialize, Clone)]
struct Quote {
ticker: String,
price: f64,
change: f64,
change_pct: f64,
volume: u64,
}
#[tauri::command]
fn get_quotes() -> Vec<Quote> {
// ... build and return quotes
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![get_quotes])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Three important things here:
#[tauri::command] is the macro that marks a Rust function as JavaScript-callable. Any function with this attribute can be invoked from the frontend by name. The argument types and return type are serialized through Serde, so anything that derives Serialize/Deserialize flows across the boundary cleanly.
tauri::generate_handler![get_quotes] is the macro that builds the IPC routing table at compile time. Add a new command, add it to the list. Forget to add it, and invoke() from the frontend will return an error.
tauri::Builder::default() is the runtime construction. You chain .plugin(...) calls (one per plugin you’ve registered), then .invoke_handler(...) once with all your commands, then .run(...) to actually start the app. This builder pattern is consistent across the Tauri 2 ecosystem — every plugin extends it.
The frontend bridge
src/App.tsx shows the other half of the IPC pattern:
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
interface Quote {
ticker: string;
price: number;
change: number;
change_pct: number;
volume: number;
}
function App() {
const [quotes, setQuotes] = useState<Quote[]>([]);
async function refresh() {
const data = await invoke<Quote[]>("get_quotes");
setQuotes(data);
}
// ... render
}
invoke is imported from @tauri-apps/api/core. Call it with the command name (matching the Rust function name) and an args object (empty here because get_quotes takes no arguments). The return type is whatever the Rust function returned, with the TypeScript generic parameter giving you proper static types on the JS side.
The shape of Quote is duplicated — once in Rust as a struct, once in TypeScript as an interface. They have to match. Episode 2 covers how to keep them in sync (the short answer: a generated type bindings file).
Production build output
pnpm tauri build
This is the production command. It compiles Rust in release mode, runs Vite’s production build, and packages the result per bundle.targets. On macOS you get:
src-tauri/target/release/bundle/macos/
└── Stock Watcher.app
…plus a .dmg if you’ve got disk image bundling enabled. The release binary is typically 5–15 MB depending on which plugins you’ve included. The equivalent Electron app would be 150 MB+. That’s the headline reason to use Tauri at all.
Recap
Three takeaways for episode 1:
- Two halves, never mix.
src/(React) andsrc-tauri/(Rust) are strictly separated. The bridge isinvoke()on JS /#[tauri::command]on Rust. - Capabilities replaced the 1.x allowlist. Each capability is a JSON file granting specific permissions to specific windows. Security is explicit by default.
- Plugins are first-class. Tauri 2’s core is minimal. Pull only the plugins you need. Each plugin has its own crate, its own JS API, and its own permission identifiers.
Next episode: IPC patterns. Commands, events, state — the three-way bridge between Rust and your frontend. We’ll cover the #[tauri::command] patterns that are robust under load (async, errors, streaming), how events flow in both directions, and how to share state without globals.
This channel is run by Claude AI. Tutorials AI-produced; reviewed and published by Codegiz.