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 one Transport interface with two implementations: createIpcTransport() for the desktop app and createHttpTransport() for a browser. Your frontend code calls the same methods either way. IPC passes flat arguments and results; the HTTP transport speaks the server’s JSON:API wire and flattens it back into the same entity types.

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:

    [package]
    name = "my-app"
    [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"] }
    schemars = "0.8"
    thiserror = "2"

    specta and schemars are dependencies of the generated DTOs (Create{Entity}Input / Update{Entity}Input) which derive specta::Type and JsonSchema. The TypeScript bindings do not go through specta: ontogen-ts emits them at build time — see the TypeScript bindings guide.

  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::clients::ClientGenerator;
    use ontogen::servers::{NamingConfig, ServerGenerator};
    use ontogen::{ClientsConfig, 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(),
    },
    ],
    sse_route_overrides: Default::default(),
    route_prefix: None,
    store_type: Some("Store".into()),
    store_import: Some("crate::store::Store".into()),
    pagination: None,
    extra_surfaces: vec![],
    error_source_dir: None, // Pipeline fills in its schema directory
    };
    let clients_config = ClientsConfig {
    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(),
    },
    ],
    store_type: Some("Store".into()),
    store_import: Some("crate::store::Store".into()),
    // Every other field at its inert default: no formatting, no
    // route prefix, no pagination.
    ..ClientsConfig::new("src/api/v1", "AppState", "crate::api::v1", "crate::schema", "crate::AppState")
    };
    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()))
    .store_id_strategy(ontogen::IdStrategy::Provided)
    .api("src/api/v1/generated", "AppState")
    .servers(servers_config)
    .clients(clients_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 client config’s route_prefix, pagination and naming must match the server config’s, so the HTTP transport calls the routes the server serves.

  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. The generated IPC module ends with ipc_handler(), which wraps tauri::generate_handler! over every generated command. In src/main.rs, pass it to Tauri:

    use std::sync::Arc;
    fn main() {
    tauri::Builder::default()
    .setup(|app| {
    // Initialize your database
    let db = /* create DatabaseConnection */;
    let state = Arc::new(my_app::AppState::new(db));
    app.manage(state);
    Ok(())
    })
    .invoke_handler(my_app::api::transport::ipc::generated::ipc_handler())
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
    }

    Each entity gets five IPC commands named {entity}_{op}: exercise_list, exercise_get_by_id, exercise_create, exercise_update, exercise_delete. A custom endpoint is {module}_{fn} with the module singularized (stats::get_workout is stat_get_workout), and an event op adds a {event}_subscribe / {event}_unsubscribe pair.

  5. The generated transport.ts exports the Transport interface and its two implementations. Pick one when the app starts:

    import { createHttpTransport, createIpcTransport } from '~/generated/transport';
    const transport = '__TAURI_INTERNALS__' in window ? createIpcTransport() : createHttpTransport();
    const exercises = await transport.exerciseList({ sort: ['name'] });
    const newExercise = await transport.exerciseCreate({
    id: 'ex-bench-press',
    name: 'Bench Press',
    muscle_group: 'chest',
    equipment: 'barbell',
    notes: null,
    });

    createIpcTransport() calls invoke('exercise_create', { input }) and gets the Exercise back as is. createHttpTransport() sends POST /api/exercises as a JSON:API document, reads the resource object it answers, and returns the same flat Exercise; a failure throws a JsonApiError carrying the HTTP status and the errors array. See Client Generation.

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

#[::tauri::command]
pub async fn exercise_list(
sort: Option<Vec<String>>,
ontogen_state: ::tauri::State<'_, ::std::sync::Arc<AppState>>,
) -> Result<Vec<Exercise>, String> {
let ontogen_order =
::ontogen_core::order::parse_sort(sort.unwrap_or_default()).map_err(|ontogen_e| ontogen_e.to_string())?;
let ontogen_store = ontogen_state.store().await.map_err(|ontogen_e| ontogen_e.to_string())?;
exercise::list(&ontogen_store, &ontogen_order).await.map_err(|ontogen_e| ontogen_e.to_string())
}
#[::tauri::command]
pub async fn exercise_create(
input: CreateExerciseInput,
ontogen_state: ::tauri::State<'_, ::std::sync::Arc<AppState>>,
) -> Result<Exercise, String> {
let ontogen_store = ontogen_state.store().await.map_err(|ontogen_e| ontogen_e.to_string())?;
exercise::create(&ontogen_store, input).await.map_err(|ontogen_e| ontogen_e.to_string())
}

Key things to notice:

  • ::tauri::State<'_, ::std::sync::Arc<AppState>> is Tauri’s state extractor. The AppState is the same one you registered with app.manage(). Runtime items are written by their full paths so that none collides with an entity of the same name.
  • Arguments and results are flat: the Exercise itself, not a JSON:API document. sort takes the same keys as the HTTP sort parameter (name, -name).
  • 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. The wires differ — the Axum server speaks JSON:API, IPC stays flat — but the Transport methods hide that, so calling code is the same.