Skip to content

Tauri Integration

Ontogen pairs naturally with Tauri. You define entities in the Tauri crate’s src/schema/, run the generator pipeline in build.rs, and get Tauri IPC command handlers alongside your Axum HTTP handlers — both generated from the same API surface.

The TypeScript client generator produces a split transport layer that uses Tauri IPC when running as a desktop app and HTTP when running in a browser. Your frontend code calls the same functions either way.

This recipe walks through setting up a Tauri + Ontogen project from scratch.

A typical Tauri + Ontogen project has two codebases side by side:

my-app/
src-tauri/ # Tauri Rust crate
build.rs # Ontogen pipeline + tauri_build
Cargo.toml
src/
schema/ # Your entity definitions
persistence/db/ # Generated SeaORM entities + conversions
store/ # Generated CRUD + your hooks
api/v1/ # Generated API + your custom endpoints
api/transport/http/ # Generated Axum handlers
api/transport/ipc/ # Generated Tauri IPC handlers
lib.rs
main.rs
src-nuxt/ # (or src-vue, src-react, etc.)
app/generated/
transport.ts # Generated TypeScript client
types.ts # Type bindings
  1. In src-tauri/Cargo.toml:

    [build-dependencies]
    ontogen = "0.1"
    tauri-build = { version = "2", features = [] }
    [dependencies]
    ontogen-macros = "0.1"
    tauri = { version = "2", features = [] }
    sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-rustls"] }
    serde = { version = "1", features = ["derive"] }
    serde_json = "1"
    tokio = { version = "1", features = ["full"] }
    axum = "0.8"
    specta = { version = "=2.0.0-rc.23", features = ["derive", "function"] }
    schemars = "0.8"
    thiserror = "2"

    specta and schemars are needed because the generated IPC commands use #[specta::specta] for TypeScript type generation and JsonSchema derives in DTOs.

  2. Create src-tauri/build.rs using the Pipeline builder. This mirrors the canonical examples/iron-log/src-tauri/build.rs:

    use std::path::PathBuf;
    use ontogen::servers::{ClientGenerator, NamingConfig, ServerGenerator};
    use ontogen::{Pipeline, ServersConfig};
    fn main() {
    println!("cargo:rerun-if-changed=build.rs");
    ontogen::emit_rerun_directives_excluding(
    &PathBuf::from("src/api/v1"),
    &["generated"],
    );
    let servers_config = ServersConfig {
    api_dir: "src/api/v1".into(),
    state_type: "AppState".into(),
    service_import_path: "crate::api::v1".into(),
    types_import_path: "crate::schema".into(),
    state_import: "crate::AppState".into(),
    naming: NamingConfig::default(),
    generators: vec![
    ServerGenerator::HttpAxum {
    output: "src/api/transport/http/generated.rs".into(),
    },
    ServerGenerator::TauriIpc {
    output: "src/api/transport/ipc/generated.rs".into(),
    },
    ],
    client_generators: vec![
    ClientGenerator::HttpTauriIpcSplit {
    output: "../src-nuxt/app/generated/transport.ts".into(),
    bindings_path: "../src-nuxt/app/generated/types.ts".into(),
    },
    ClientGenerator::AdminRegistry {
    output: "../src-nuxt/app/admin/generated/admin-registry.ts".into(),
    },
    ],
    rustfmt_edition: "2024".into(),
    sse_route_overrides: Default::default(),
    ts_skip_commands: vec![],
    route_prefix: None,
    store_type: Some("Store".into()),
    store_import: Some("crate::store::Store".into()),
    pagination: None,
    // Pipeline auto-fills this from parsed schema; leave empty.
    schema_entities: Vec::new(),
    };
    Pipeline::new("src/schema")
    .seaorm(
    "src/persistence/db/entities/generated",
    "src/persistence/db/conversions/generated",
    )
    .dtos("src/schema/dto")
    .store("src/store/generated", Some::<PathBuf>("src/store/hooks".into()))
    .api("src/api/v1/generated", "AppState")
    .servers(servers_config)
    .build()
    .unwrap_or_else(|e| {
    e.emit_cargo_warning();
    panic!("ontogen pipeline failed: {e}");
    });
    tauri_build::build();
    }

    Notice tauri_build::build() at the end. Ontogen runs first, generates all the code, and then Tauri’s build step runs. Order matters.

    The ontogen::servers::* re-exports (ClientGenerator, NamingConfig, ServerGenerator) replace the older ontogen::servers::config::* and ontogen::servers::types::* paths, which are now pub(crate).

  3. The generated IPC handlers expect an AppState type with a store() method. In src/lib.rs:

    pub mod api;
    pub mod persistence;
    pub mod schema;
    pub mod store;
    use std::sync::Arc;
    use sea_orm::DatabaseConnection;
    use crate::schema::AppError;
    use crate::store::Store;
    pub struct AppState {
    store: Store,
    }
    impl AppState {
    pub fn new(db: Arc<DatabaseConnection>) -> Self {
    Self { store: Store::new(db) }
    }
    pub async fn store(&self) -> Result<&Store, AppError> {
    Ok(&self.store)
    }
    }

    The generated IPC handlers call state.store().await? to get the store reference. The async on store() lets you do async initialization or project scoping in more complex setups.

  4. In src/main.rs, register the generated commands with Tauri:

    use std::sync::Arc;
    fn main() {
    tauri::Builder::default()
    .setup(|app| {
    // Initialize your database
    let db = /* create DatabaseConnection */;
    let state = Arc::new(iron_log::AppState::new(db));
    app.manage(state);
    Ok(())
    })
    .invoke_handler(tauri::generate_handler![
    // Import from your generated IPC module
    iron_log::api::transport::ipc::generated::get_exercises,
    iron_log::api::transport::ipc::generated::get_exercise_by_id,
    iron_log::api::transport::ipc::generated::create_exercise,
    iron_log::api::transport::ipc::generated::update_exercise,
    iron_log::api::transport::ipc::generated::delete_exercise,
    // ... repeat for each entity
    ])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
    }

    Each entity gets five IPC commands (list, get, create, update, delete). The generated command names follow the pattern get_{entities}, get_{entity}_by_id, create_{entity}, update_{entity}, delete_{entity}.

  5. The generated transport.ts gives you typed functions that work in both web and desktop contexts:

    import { getExercises, createExercise } from '~/generated/transport';
    // Works via Tauri IPC in desktop, HTTP in browser
    const exercises = await getExercises();
    const newExercise = await createExercise({
    id: 'ex-bench-press',
    name: 'Bench Press',
    muscle_group: 'chest',
    equipment: 'barbell',
    });

    The split transport detects at runtime whether Tauri’s IPC is available. If it is, calls go through invoke(). If not, they fall back to HTTP fetch.

Each entity’s CRUD operations become Tauri commands. Here’s what the generator produces for an Exercise entity:

#[tauri::command]
#[specta::specta]
pub async fn get_exercises(
state: State<'_, Arc<AppState>>,
) -> Result<Vec<Exercise>, String> {
let store = state.store().await.map_err(|e| e.to_string())?;
exercise::list(&store).await.map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
pub async fn create_exercise(
input: CreateExerciseInput,
state: State<'_, Arc<AppState>>,
) -> Result<Exercise, String> {
let store = state.store().await.map_err(|e| e.to_string())?;
exercise::create(&store, input).await.map_err(|e| e.to_string())
}

Key things to notice:

  • State<'_, Arc<AppState>> is Tauri’s state extractor. The AppState is the same one you registered with app.manage().
  • #[specta::specta] enables TypeScript type generation from the Rust signatures.
  • Errors are mapped to String because Tauri IPC requires string-serializable errors.
  • The handler delegates to the API layer (exercise::list, exercise::create), which delegates to the store.

The power of Ontogen’s pipeline is that both transport layers (HTTP and IPC) are generated from the same ApiOutput. Adding a new entity or custom endpoint automatically appears in both.