Build Your First API with Ultimo in 10 Minutes
Ultimo lets you build REST and JSON-RPC APIs in Rust with automatic TypeScript client generation. In this tutorial, you'll create a fully functional Todo API, expose it via both REST and JSON-RPC, and generate a typed TypeScript client that a frontend can consume immediately.
By the end, you'll have a running API server, curl commands to test it, and a generated TypeScript client ready to drop into any React, Vue, or Svelte project.
Prerequisites
Before we start, make sure you have:
- Rust 1.86+ — install via rustup (
rustup update stable) - Node.js 18+ — needed for the TypeScript client generation step
- cargo and npm/pnpm available in your PATH
Verify your setup:
rustc --version # should be 1.86.0 or newer
node --version # should be v18 or newerStep 1: Create Your Project
Start by creating a new Rust project and installing the Ultimo CLI:
cargo new todo-api
cd todo-api
cargo install ultimo-cliAlternatively, you can scaffold with the CLI directly:
ultimo new todo-api --template api-only
cd todo-apiStep 2: Configure Dependencies
Replace your Cargo.toml with the following:
[package]
name = "todo-api"
version = "0.1.0"
edition = "2021"
[dependencies]
ultimo = { version = "0.5", features = ["websocket", "session"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }Run cargo build to fetch dependencies — the ultimo crate and its transitive deps. This will take a moment on the first run as it compiles Ultimo and its dependencies.
Step 3: Define Your Data Types
Open src/main.rs and start by defining the data structures for our Todo API. Ultimo uses standard Serde derives plus its own TS derive macro for TypeScript generation:
use serde::{Deserialize, Serialize};
use ultimo::prelude::*;
use uuid::Uuid;
use std::sync::Arc;
use tokio::sync::RwLock;
/// A single todo item
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct Todo {
pub id: String,
pub title: String,
pub completed: bool,
}
/// Request body for creating a new todo
#[derive(Debug, Deserialize, TS)]
pub struct CreateTodo {
pub title: String,
}
/// Request body for updating an existing todo
#[derive(Debug, Deserialize, TS)]
pub struct UpdateTodo {
pub title: Option<String>,
pub completed: Option<bool>,
}
/// Shared application state
#[derive(Clone)]
pub struct AppState {
pub todos: Arc<RwLock<Vec<Todo>>>,
}The TS derive macro tells Ultimo's code generator to include these types in the generated TypeScript client. You don't need to write TypeScript interfaces manually — they're produced automatically from your Rust types.
Step 4: Create REST Handlers
Now let's write the handler functions. Each handler receives a Context that provides access to request data, application state, path parameters, and response builders.
List All Todos
async fn list_todos(ctx: Context) -> Result<impl Into<Response>> {
let state = ctx.state::<AppState>();
let todos = state.todos.read().await;
ctx.json(&*todos)
}Create a Todo
async fn create_todo(ctx: Context) -> Result<impl Into<Response>> {
let state = ctx.state::<AppState>();
let body: CreateTodo = ctx.json_body().await?;
let todo = Todo {
id: Uuid::new_v4().to_string(),
title: body.title,
completed: false,
};
state.todos.write().await.push(todo.clone());
ctx.status(201).json(&todo)
}Get a Single Todo
async fn get_todo(ctx: Context) -> Result<impl Into<Response>> {
let state = ctx.state::<AppState>();
let id = ctx.param("id")?;
let todos = state.todos.read().await;
let todo = todos.iter().find(|t| t.id == id);
match todo {
Some(t) => ctx.json(t),
None => Err(UltimoError::not_found("Todo not found")),
}
}Update a Todo
async fn update_todo(ctx: Context) -> Result<impl Into<Response>> {
let state = ctx.state::<AppState>();
let id = ctx.param("id")?;
let body: UpdateTodo = ctx.json_body().await?;
let mut todos = state.todos.write().await;
let todo = todos.iter_mut().find(|t| t.id == id);
match todo {
Some(t) => {
if let Some(title) = body.title {
t.title = title;
}
if let Some(completed) = body.completed {
t.completed = completed;
}
ctx.json(t)
}
None => Err(UltimoError::not_found("Todo not found")),
}
}Delete a Todo
async fn delete_todo(ctx: Context) -> Result<impl Into<Response>> {
let state = ctx.state::<AppState>();
let id = ctx.param("id")?;
let mut todos = state.todos.write().await;
let len_before = todos.len();
todos.retain(|t| t.id != id);
if todos.len() == len_before {
Err(UltimoError::not_found("Todo not found"))
} else {
ctx.status(204).text("")
}
}Each handler follows the same pattern: extract state, parse input, perform logic, return a response. Ultimo's Context handles serialization, content-type headers, and error formatting automatically.
Step 5: Add JSON-RPC Endpoints
One of Ultimo's standout features is serving JSON-RPC alongside REST from the same application. JSON-RPC is ideal for frontend clients because it uses a single endpoint and maps cleanly to generated TypeScript functions.
Define an RPC registry with query (read) and mutation (write) methods:
fn build_rpc() -> RpcRegistry {
let mut rpc = RpcRegistry::new();
// Queries (read-only operations)
rpc.query("todos.list", |ctx: Context, _params: ()| async move {
let state = ctx.state::<AppState>();
let todos = state.todos.read().await;
Ok(serde_json::to_value(&*todos)?)
});
rpc.query("todos.get", |ctx: Context, params: serde_json::Value| async move {
let id = params["id"].as_str().unwrap_or_default();
let state = ctx.state::<AppState>();
let todos = state.todos.read().await;
todos
.iter()
.find(|t| t.id == id)
.map(|t| serde_json::to_value(t).unwrap())
.ok_or_else(|| UltimoError::not_found("Todo not found"))
});
// Mutations (write operations)
rpc.mutation("todos.create", |ctx: Context, params: CreateTodo| async move {
let state = ctx.state::<AppState>();
let todo = Todo {
id: Uuid::new_v4().to_string(),
title: params.title,
completed: false,
};
state.todos.write().await.push(todo.clone());
Ok(serde_json::to_value(&todo)?)
});
rpc.mutation("todos.toggle", |ctx: Context, params: serde_json::Value| async move {
let id = params["id"].as_str().unwrap_or_default();
let state = ctx.state::<AppState>();
let mut todos = state.todos.write().await;
let todo = todos.iter_mut().find(|t| t.id == id)
.ok_or_else(|| UltimoError::not_found("Todo not found"))?;
todo.completed = !todo.completed;
Ok(serde_json::to_value(&todo)?)
});
rpc
}The distinction between query and mutation is semantic — queries are safe to cache and retry, mutations are not. The TypeScript client generator uses this distinction to produce appropriately typed function signatures.
Step 6: Wire Everything Together
Now let's assemble the application in main():
#[tokio::main]
async fn main() -> Result<()> {
let state = AppState {
todos: Arc::new(RwLock::new(vec![])),
};
let rpc = build_rpc();
let app = Ultimo::new()
.state(state)
.rpc(rpc)
// REST routes
.get("/api/todos", list_todos)
.post("/api/todos", create_todo)
.get("/api/todos/:id", get_todo)
.put("/api/todos/:id", update_todo)
.delete("/api/todos/:id", delete_todo)
// Middleware
.middleware(ultimo::middleware::Logger::new())
.middleware(ultimo::middleware::Cors::permissive())
.bind("127.0.0.1:3000")
.await?;
println!("🚀 Server running at http://127.0.0.1:3000");
println!(" REST: http://127.0.0.1:3000/api/todos");
println!(" JSON-RPC: http://127.0.0.1:3000/rpc");
app.run().await
}The Ultimo builder chains everything together: shared state, RPC registry, REST routes, and middleware — all running on Tokio. The JSON-RPC endpoint is automatically mounted at /rpc when you attach an RpcRegistry.
Step 7: Run and Test
Start the server:
cargo runNow test with curl. Create a todo:
curl -X POST http://127.0.0.1:3000/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "Learn Ultimo"}'Response:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "Learn Ultimo",
"completed": false
}List all todos:
curl http://127.0.0.1:3000/api/todosUpdate a todo (replace the ID with your actual ID):
curl -X PUT http://127.0.0.1:3000/api/todos/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Content-Type: application/json" \
-d '{"completed": true}'Test the JSON-RPC endpoint:
curl -X POST http://127.0.0.1:3000/rpc \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "method": "todos.create", "params": {"title": "Ship it"}, "id": 1}'Response:
{
"jsonrpc": "2.0",
"result": {
"id": "f7e8d9c0-b1a2-3456-7890-abcdef123456",
"title": "Ship it",
"completed": false
},
"id": 1
}Step 8: Generate the TypeScript Client
This is where Ultimo truly shines. Run the CLI generator to produce a fully typed TypeScript client from your Rust API:
ultimo generate --path ./src --output ./clientThis scans your Rust source files, finds all types annotated with TS and all registered RPC methods, and generates a typed client. The output in ./client/index.ts looks like this:
// Auto-generated by ultimo-cli — do not edit manually
export interface Todo {
id: string;
title: string;
completed: boolean;
}
export interface CreateTodo {
title: string;
}
export interface UpdateTodo {
title?: string;
completed?: boolean;
}
export class TodoApiClient {
private baseUrl: string;
constructor(baseUrl: string = "http://127.0.0.1:3000") {
this.baseUrl = baseUrl;
}
// Queries
async todosList(): Promise<Todo[]> {
return this.rpcCall("todos.list", {});
}
async todosGet(params: { id: string }): Promise<Todo> {
return this.rpcCall("todos.get", params);
}
// Mutations
async todosCreate(params: CreateTodo): Promise<Todo> {
return this.rpcCall("todos.create", params);
}
async todosToggle(params: { id: string }): Promise<Todo> {
return this.rpcCall("todos.toggle", params);
}
private async rpcCall<T>(method: string, params: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}/rpc`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method,
params,
id: Date.now(),
}),
});
const json = await res.json();
if (json.error) throw new Error(json.error.message);
return json.result as T;
}
}Every type is correct, every method signature matches your Rust definitions, and the client handles JSON-RPC framing for you.
Step 9: Use the Client in a Frontend
Drop the generated client into any TypeScript project:
import { TodoApiClient } from "./client";
const api = new TodoApiClient("http://localhost:3000");
// Create a todo — fully typed, autocompletion works
const newTodo = await api.todosCreate({ title: "Write docs" });
console.log(newTodo.id); // string, not any
// List all todos
const todos = await api.todosList();
todos.forEach((todo) => {
console.log(`${todo.completed ? "✓" : "○"} ${todo.title}`);
});
// Toggle completion
const updated = await api.todosToggle({ id: newTodo.id });
console.log(updated.completed); // trueNo manual API wiring, no fetch boilerplate, no type mismatches between frontend and backend — a key advantage if you're choosing between Rust frameworks. Change a field in your Rust struct, re-run ultimo generate, and the TypeScript compiler immediately flags any frontend code that uses the old shape.
For continuous development, use watch mode:
ultimo generate --path ./src --output ./client --watchThis regenerates the client whenever your Rust source files change.
Step 10: Add Middleware
Ultimo ships with built-in middleware for common needs. You already saw Logger and Cors in the main function. Here's how to add more:
use ultimo::middleware::{Logger, Cors, RateLimit, SecurityHeaders};
use std::time::Duration;
let app = Ultimo::new()
.state(state)
.rpc(rpc)
// Logging — prints method, path, status, and duration
.middleware(Logger::new())
// CORS — permissive for development, restrict in production
.middleware(Cors::permissive())
// Rate limiting — 100 requests per minute per IP
.middleware(RateLimit::new(100, Duration::from_secs(60)))
// Security headers — X-Content-Type-Options, X-Frame-Options, etc.
.middleware(SecurityHeaders::new())
// ... routes ...
.bind("127.0.0.1:3000")
.await?;You can also write custom middleware. A middleware is any async function that wraps the next handler:
async fn auth_middleware(ctx: Context, next: Next) -> Result<impl Into<Response>> {
let token = ctx.header("Authorization").unwrap_or_default();
if !token.starts_with("Bearer ") {
return Err(UltimoError::unauthorized("Missing token"));
}
// Token is valid — continue to the handler
next.run(ctx).await
}Apply it to specific routes or route groups as needed.
Complete Source Code
Here's the full src/main.rs for reference — you can copy this directly and have a working API:
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use ultimo::prelude::*;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct Todo {
pub id: String,
pub title: String,
pub completed: bool,
}
#[derive(Debug, Deserialize, TS)]
pub struct CreateTodo {
pub title: String,
}
#[derive(Debug, Deserialize, TS)]
pub struct UpdateTodo {
pub title: Option<String>,
pub completed: Option<bool>,
}
#[derive(Clone)]
pub struct AppState {
pub todos: Arc<RwLock<Vec<Todo>>>,
}
async fn list_todos(ctx: Context) -> Result<impl Into<Response>> {
let state = ctx.state::<AppState>();
let todos = state.todos.read().await;
ctx.json(&*todos)
}
async fn create_todo(ctx: Context) -> Result<impl Into<Response>> {
let state = ctx.state::<AppState>();
let body: CreateTodo = ctx.json_body().await?;
let todo = Todo {
id: Uuid::new_v4().to_string(),
title: body.title,
completed: false,
};
state.todos.write().await.push(todo.clone());
ctx.status(201).json(&todo)
}
async fn get_todo(ctx: Context) -> Result<impl Into<Response>> {
let state = ctx.state::<AppState>();
let id = ctx.param("id")?;
let todos = state.todos.read().await;
match todos.iter().find(|t| t.id == id) {
Some(t) => ctx.json(t),
None => Err(UltimoError::not_found("Todo not found")),
}
}
async fn update_todo(ctx: Context) -> Result<impl Into<Response>> {
let state = ctx.state::<AppState>();
let id = ctx.param("id")?;
let body: UpdateTodo = ctx.json_body().await?;
let mut todos = state.todos.write().await;
match todos.iter_mut().find(|t| t.id == id) {
Some(t) => {
if let Some(title) = body.title {
t.title = title;
}
if let Some(completed) = body.completed {
t.completed = completed;
}
ctx.json(t)
}
None => Err(UltimoError::not_found("Todo not found")),
}
}
async fn delete_todo(ctx: Context) -> Result<impl Into<Response>> {
let state = ctx.state::<AppState>();
let id = ctx.param("id")?;
let mut todos = state.todos.write().await;
let len_before = todos.len();
todos.retain(|t| t.id != id);
if todos.len() == len_before {
Err(UltimoError::not_found("Todo not found"))
} else {
ctx.status(204).text("")
}
}
fn build_rpc() -> RpcRegistry {
let mut rpc = RpcRegistry::new();
rpc.query("todos.list", |ctx: Context, _params: ()| async move {
let state = ctx.state::<AppState>();
let todos = state.todos.read().await;
Ok(serde_json::to_value(&*todos)?)
});
rpc.query("todos.get", |ctx: Context, params: serde_json::Value| async move {
let id = params["id"].as_str().unwrap_or_default();
let state = ctx.state::<AppState>();
let todos = state.todos.read().await;
todos
.iter()
.find(|t| t.id == id)
.map(|t| serde_json::to_value(t).unwrap())
.ok_or_else(|| UltimoError::not_found("Todo not found"))
});
rpc.mutation("todos.create", |ctx: Context, params: CreateTodo| async move {
let state = ctx.state::<AppState>();
let todo = Todo {
id: Uuid::new_v4().to_string(),
title: params.title,
completed: false,
};
state.todos.write().await.push(todo.clone());
Ok(serde_json::to_value(&todo)?)
});
rpc.mutation("todos.toggle", |ctx: Context, params: serde_json::Value| async move {
let id = params["id"].as_str().unwrap_or_default();
let state = ctx.state::<AppState>();
let mut todos = state.todos.write().await;
let todo = todos.iter_mut().find(|t| t.id == id)
.ok_or_else(|| UltimoError::not_found("Todo not found"))?;
todo.completed = !todo.completed;
Ok(serde_json::to_value(&todo)?)
});
rpc
}
#[tokio::main]
async fn main() -> Result<()> {
let state = AppState {
todos: Arc::new(RwLock::new(vec![])),
};
let rpc = build_rpc();
let app = Ultimo::new()
.state(state)
.rpc(rpc)
.get("/api/todos", list_todos)
.post("/api/todos", create_todo)
.get("/api/todos/:id", get_todo)
.put("/api/todos/:id", update_todo)
.delete("/api/todos/:id", delete_todo)
.middleware(ultimo::middleware::Logger::new())
.middleware(ultimo::middleware::Cors::permissive())
.bind("127.0.0.1:3000")
.await?;
println!("Server running at http://127.0.0.1:3000");
app.run().await
}Next Steps
You now have a working API with both REST and JSON-RPC interfaces, plus a generated TypeScript client. Here's where to go from here:
-
Add a database — Ultimo supports SQLx (Postgres, MySQL, SQLite) and Diesel out of the box. Enable the
sqlx-sqlitefeature and replace the in-memoryVecwith a real database. See the database guide in the API documentation. -
Add authentication — Use JWT tokens or API keys with Ultimo's auth guards. The
jwt-authexample in the repository shows the full pattern including token issuance, validation middleware, and protected routes. -
Add WebSocket — Enable the
websocketfeature for real-time updates. Ultimo's pub/sub system lets you broadcast todo changes to connected clients with just a few lines of code. -
Add validation — Use Ultimo's
validatefunction to enforce constraints on request bodies before processing them. -
Deploy — Ultimo compiles to a single static binary. Build with
ultimo build --profile release, copy the binary to your server, and run it. No runtime dependencies needed. If you're coming from another framework, the migration guide covers common patterns.
Check out the full documentation and the examples directory for more patterns and real-world usage. Also see what's new in v0.5.0 for the latest additions.
Have questions? Open an issue on GitHub or join the discussion in the repository's Discussions tab.