Server functions
Server functionsDefine server logic — including database queries — directly inside your app, as if the client were running it. The compiler splits the paths based on the build target: the server runs the body, the client turns the call site into a typed network request, and the matching server-side handler is registered automatically.
What server functions areYou write the function once. The body runs database queries, reads request headers, touches whatever server-side state your handler needs — all expressed in plain Rust. The call site — in your UI component, on the same `await` you'd use for a local async fn — reads as if the client itself were running that body.
// In your app crate, alongside your UI code:

use server::{server, ServerError};

#[server]
async fn list_todos(user_id: u64) -> Result<Vec<Todo>, ServerError> {
    let db = server::use_state::<Arc<Db>>()
        .ok_or_else(|| ServerError::failed("Db not installed"))?;
    db.query("SELECT * FROM todos WHERE user_id = $1", &[&user_id]).await
}

// In the very same crate, in your UI component:
let todos = list_todos(current_user.id).await?;
Under the hood, the `#[server]` macro splits the function based on the build target. On the SERVER build, the body compiles verbatim and a handler gets auto-registered at `/_srv/list_todos`. On the CLIENT build, the body is discarded — `list_todos(user_id)` becomes a POST that ships `[user_id]` to the server, awaits the response, and decodes it back into `Result<Vec<Todo>, ServerError>`. The signature you wrote IS the wire contract; the compiler checks it on both sides.The result is one mental model. You're not maintaining a client API and a server API and a DTO crate in lockstep — you're writing one Rust function that happens to execute across a network boundary. The boundary is a compile-time decision, not a code-organization tax.
How the macro splits`#[server]` is an attribute macro. It expands the async fn into two cfg-gated halves and keys off the `server` cargo feature to decide which half each build sees.
// Server build: --features server
async fn add(a: i32, b: i32) -> Result<i32, ServerError> {
    Ok(a + b)                                  // original body
}

// Plus an inventory::submit! that registers a handler:
// POST /_srv/add → decode args → call add(a, b) → encode result
// Client build: default features
async fn add(a: i32, b: i32) -> Result<i32, ServerError> {
    server::__private::call::<(i32, i32), _>("add", &(a, b)).await
}
Both halves see the same source file. Only one ends up compiled into each artifact — the server-only body (and any imports it uses: Diesel, tokio, your DB pool type) never reach the client bundle.
The wireJSON over HTTP. Two routes: single and batched. The framework picks single vs batch automatically based on how many calls you fire in the same tick.
# single call
POST /_srv/<path>
Content-Type: application/json

[arg0, arg1, ...]                  →  {"Ok": T} | {"Err": E}

# batched calls (microtask-coalesced)
POST /_srv/_batch
[{"path": "add",     "args": [2, 3]},
 {"path": "v1/ping", "args": null}]   →  [{"Ok": 5}, {"Ok": "pong"}]
Status codes are reserved for dispatcher-level failures — 404 for unknown path, 400 for malformed args. A function that returned `Err(...)` still gets a 200 response with `{"Err": ...}` in the body. That keeps domain errors and transport errors visibly separate on the client side.
Project layoutThe recommended layout is three crates. The `shared/` crate is the dual-feature one — it compiles twice, once with `--features server` (the body runs, with access to your DB / state / imports), once without (the body is replaced with the RPC stub).
my-app/
├── shared/   # types + #[server] fns + cfg-gated server state
├── server/   # bin, depends on shared with features=["server"]
└── client/   # one or more clients (web wasm, native, mobile);
   # depend on shared with default features
Server-only deps (Diesel, Redis, tokio, anything that has no business in a wasm bundle) are declared `optional = true` and activated only by the `server` feature. The macro discards server fn bodies on the client side entirely — so references to Diesel inside those bodies never reach the client compilation. Import shape matters too:
// shared/src/server_fns.rs

// ❌ leaks: `use diesel::*` at module scope compiles in
// both modes. If diesel isn't in the client's dep graph, this errors.
use diesel::prelude::*;

// ✅ clean: cfg-gated import, only compiled with the server half
#[cfg(feature = "server")]
use diesel::prelude::*;
App state and per-request dataServer-side code gets two flavors of context. App-level state (DB pool, config, S3 client) is registered once at startup and read with `use_state::<T>` — the registry is `TypeId`-keyed, so install one of each type:
// At server startup:
server::install_state(Arc::new(Db::connect().await));

// Inside any server fn body:
#[server]
async fn list_todos(user_id: u64) -> Result<Vec<Todo>, ServerError> {
    let db = server::use_state::<Arc<Db>>()
        .ok_or_else(|| ServerError::failed("Db not installed"))?;
    db.query(...).await
}
Per-request data (headers today; authenticated user / trace id / extracted query params later) is set by the dispatcher into a `tokio::task_local!` scope before invoking the handler:
#[server]
async fn whoami() -> Result<String, ServerError> {
    let auth = server::use_request_header("authorization")
        .ok_or_else(|| ServerError::failed("missing Authorization"))?;
    Ok(format!("authenticated as: {auth}"))
}
Both extractors are server-only — they don't exist in the client build, so importing them inside a `#[server]` body is safe; that body never reaches the wasm compilation.
Batching, for freeMultiple server-fn calls fired in the same tick coalesce into a single HTTP request. The mechanism is inline microtask coalescing: each call enqueues, the first one becomes the flusher, yields once for siblings to enqueue, then drains the queue into one `POST /_srv/_batch`.
// Three calls in the same tick:
let (user, todos, projects) = tokio::join!(
    get_user(uid),
    list_todos(uid),
    list_projects(),
);

// → one POST /_srv/_batch on the wire, not three.
On a typical app-load fan-out — `use_query(get_user)` + `use_query(list_todos)` + `use_query(list_projects)` — you go from three round-trips to one. Authors don't opt in. Open the network tab in any app that uses server functions and you'll see `_srv/_batch` lines for every page mount.
Cancellation, end-to-endWhen a `resource` fetcher's deps change, the in-flight server-fn call should actually abort — not just have its result discarded. `server::with_cancel(...)` bridges the reactive system's `ResourceCancel` token to the HTTP transport's cancel primitive, all the way down to the per-platform stack.
let user_id = signal(1u64);

let user = resource(user_id, |id, resource_cancel| async move {
    server::with_cancel(resource_cancel, get_user(id)).await
});

// `user_id.set(2)` cancels:
//   1. the resource's prior fetch (ResourceCancel)
//   2. the in-flight HTTP request (net::CancelToken)
//   3. the actual network read (reqwest drops / browser aborts / iOS 
//      task.cancel / Android conn.disconnect)
Cancellation interop with batching: if a cancellable call is still queued when its token fires, the flusher removes it from the batch before sending. If it's already in flight, the HTTP completes (the other calls in the batch deserve their results) but the cancelled caller still returns `Cancelled`.
Wiring into the UIServer functions are async fns. They compose with every reactive async primitive: `resource()` for dep-driven reads, `mutation()` for fire-and-forget writes, `async_reducer()` for writes that fold their response into local state — the workhorse pattern for any mutation that updates a list / map / record.
let todos: Signal<Vec<Todo>> = signal!(Vec::new());

// load on mount, refresh on dep change
let refresh = async_reducer(
    todos,
    |_| async { list_todos().await },
    |list, new_list| *list = new_list,
);

// mutation that folds response straight into local state
let create = async_reducer(
    todos,
    |input| async move { create_todo(input).await },
    |list, new_todo| list.push(new_todo),
);
Each reducer exposes loading / error state via its own `AsyncStatus<E>` signal, so UI bindings get spinners + error rendering for free. The data lives in your `Signal<S>`; the lifecycle lives on the handle.
Running itDeclare `server_bin = "<name>"` in your manifest and the CLI runs the full stack with one command — builds the wasm bundle into `pkg/`, launches `cargo run --bin <name> --features server`, and watches your source for changes. Every edit triggers a fresh wasm build + a server restart.
# Cargo.toml
[package.metadata.idealyst.app]
targets    = ["web"]
server_bin = "server"      # opt the project into the full-stack flow

# one commandbuilds wasm, runs the server bin, watches src/ for changes:
idealyst dev --web --local my-app
The server bin serves both the API (at `/_srv/*`) AND the wasm bundle (at `/` and `/pkg/*`) on one port. Open the URL it prints and the whole app — UI + API — comes from one process.
Where to go from hereServer functions plug into the rest of the framework through the same reactive primitives you'd use for any async work. If you haven't read the Core concepts page yet, the signals + components model is the foundation everything here builds on.Read → Core conceptsThe example app at `examples/server-fn-demo` is a runnable todo app exercising every concept on this page — CRUD, batching, cancellation, extractors, the async_reducer pattern, all of it.
On this page
What server functions are
How the macro splits
The wire
Project layout
App state & request data
Batching, for free
Cancellation, end-to-end
Wiring into the UI
Running it
Where to go from here
IdealystOne codebase, native everywhere.
© Idealyst 2026