Build web servers and APIs with Actix Web and Axum, Rust's top web frameworks.
Rust offers several powerful frameworks for web development. Each has different strengths: Axum is modular and composable, Actix-web is high-performance, Rocket focuses on developer experience, and Warp uses a functional filter approach.
// Key ecosystems and their strengths:
// Axum - Modular, composable, built on Tokio
// Actix - High performance, actor-based
// Rocket - Easy to use, focuses on developer experience
// Warp - Functional approach using filters
// Tauri - Desktop apps with web frontend
// Common web dependencies in Cargo.toml:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
// axum = "0.7"
// serde = { version = "1.0", features = ["derive"] }
// serde_json = "1.0"
// sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
// tower = "0.4"Axum is the recommended starting point for most projects, it integrates tightly with the Tokio ecosystem, has excellent composability via Tower middleware, and is maintained by the Tokio team.
Axum is a modular and composable web framework built on Tokio. Routes map HTTP methods and paths to async handler functions.
// Simple Axum server with basic routing
use axum::{
routing::{get, post},
Router,
};
async fn hello() -> &'static str {
"Hello, world!"
async fn json_handler() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({
"message": "Hello from JSON",
"status": "ok"
}))
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(hello))
.route("/json", get(json_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}// Axum with path parameters
use axum::{
extract::Path,
routing::get,
Router,
};
async fn get_user(Path(id): Path<u32>) -> String {
format!("Getting user with ID: {}", id)
async fn get_user_post(Path((id, post_id)): Path<(u32, u32)>) -> String {
format!("User {} Post {}", id, post_id)
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users/:id", get(get_user))
.route("/users/:id/posts/:post_id", get(get_user_post));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}// Axum with shared state
use axum::{
extract::State,
routing::get,
Router,
};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Clone)]
struct AppState {
counter: Arc<AtomicUsize>,
async fn increment_counter(State(state): State<AppState>) -> String {
let count = state.counter.fetch_add(1, Ordering::Relaxed);
format!("Counter: {}", count + 1)
#[tokio::main]
async fn main() {
let state = AppState {
counter: Arc::new(AtomicUsize::new(0)),
};
let app = Router::new()
.route("/counter", get(increment_counter))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}Axum uses extractors (Path, State, Json, Query) as function parameters. The framework automatically deserialises the request into these types before calling your handler.
A complete CRUD REST API using Axum with shared state, JSON serialisation, and proper HTTP status codes.
// REST API for managing todos
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, post, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Clone)]
struct Todo {
id: u32,
title: String,
completed: bool,
#[derive(Serialize, Deserialize)]
struct CreateTodo {
title: String,
#[derive(Clone)]
struct AppState {
todos: Arc<RwLock<HashMap<u32, Todo>>>,
// Create todo
async fn create_todo(
State(state): State<AppState>,
Json(create): Json<CreateTodo>,
) -> (StatusCode, Json<Todo>) {
let mut todos = state.todos.write().await;
let id = todos.len() as u32 + 1;
let todo = Todo { id, title: create.title, completed: false };
todos.insert(id, todo.clone());
(StatusCode::CREATED, Json(todo))
// Get all todos
async fn list_todos(State(state): State<AppState>) -> Json<Vec<Todo>> {
let todos = state.todos.read().await;
let list: Vec<_> = todos.values().cloned().collect();
Json(list)
// Get single todo
async fn get_todo(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<Todo>, StatusCode> {
let todos = state.todos.read().await;
todos.get(&id).cloned().map(Json).ok_or(StatusCode::NOT_FOUND)
// Delete todo
async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> StatusCode {
let mut todos = state.todos.write().await;
if todos.remove(&id).is_some() {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
#[tokio::main]
async fn main() {
let state = AppState {
todos: Arc::new(RwLock::new(HashMap::new())),
};
let app = Router::new()
.route("/todos", post(create_todo).get(list_todos))
.route("/todos/:id",
get(get_todo).delete(delete_todo))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await.unwrap();
axum::serve(listener, app).await.unwrap();
}SQLx provides compile-time checked SQL queries. It verifies your queries against the actual database schema at build time, catching errors before runtime.
// SQLx integration example
use sqlx::sqlite::SqlitePool;
#[derive(Debug, Clone)]
struct User {
id: i32,
name: String,
email: String,
async fn create_user_db(
pool: &SqlitePool,
name: &str,
email: &str,
) -> Result<User, sqlx::Error> {
let user = sqlx::query_as::<_, User>(
"INSERT INTO users (name, email)
VALUES (?, ?) RETURNING id, name, email"
)
.bind(name)
.bind(email)
.fetch_one(pool)
.await?;
Ok(user)
async fn get_user_by_id(
pool: &SqlitePool,
id: i32,
) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, name, email FROM users WHERE id = ?"
)
.bind(id)
.fetch_optional(pool)
.await
async fn list_all_users(
pool: &SqlitePool,
) -> Result<Vec<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, name, email FROM users"
)
.fetch_all(pool)
.await
}SQLx can check your queries at compile time against a real database using the sqlx::query! macro. This catches typos, type mismatches, and missing columns before your code even runs.
Actix-web is a high-performance, actor-based framework. It consistently ranks among the fastest web frameworks in independent benchmarks.
// Simple Actix-web server
use actix_web::{web, App, HttpServer, HttpResponse};
async fn hello() -> HttpResponse {
HttpResponse::Ok().body("Hello from Actix!")
async fn echo(path: web::Path<String>) -> HttpResponse {
HttpResponse::Ok()
.body(format!("Echo: {}", path.into_inner()))
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.route("/", web::get().to(hello))
.route("/echo/{msg}", web::get().to(echo))
})
.bind("127.0.0.1:8000")?
.run()
.await
}Proper error handling in web applications uses custom error types that implement IntoResponse, mapping domain errors to appropriate HTTP status codes.
// Custom error handling
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
#[derive(Debug)]
enum AppError {
NotFound,
InvalidInput(String),
DatabaseError(String),
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
AppError::NotFound =>
(StatusCode::NOT_FOUND, "Resource not found"),
AppError::InvalidInput(msg) =>
(StatusCode::BAD_REQUEST, &msg),
AppError::DatabaseError(msg) =>
(StatusCode::INTERNAL_SERVER_ERROR, &msg),
};
let body = Json(json!({
"error": error_message
}));
(status, body).into_response()
}
}thiserror + anyhowIn production, combine thiserror for defining error types and anyhow for propagating errors. This gives you structured errors for your API while keeping internal error handling ergonomic.
A full working example combining routing, state management, JSON serialisation, timestamps, and proper CRUD operations.
// Production-ready CRUD API
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{delete, get, post, put},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
use chrono::{DateTime, Utc};
#[derive(Serialize, Deserialize, Clone)]
struct Article {
id: u32,
title: String,
content: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
#[derive(Serialize, Deserialize)]
struct CreateArticle { title: String, content: String }
#[derive(Serialize, Deserialize)]
struct UpdateArticle { title: Option<String>, content: Option<String> }
#[derive(Clone)]
struct AppState {
articles: Arc<RwLock<HashMap<u32, Article>>>,
async fn create_article(
State(state): State<AppState>,
Json(create): Json<CreateArticle>,
) -> (StatusCode, Json<Article>) {
let mut articles = state.articles.write().await;
let id = articles.len() as u32 + 1;
let now = Utc::now();
let article = Article {
id, title: create.title, content: create.content,
created_at: now, updated_at: now,
};
articles.insert(id, article.clone());
(StatusCode::CREATED, Json(article))
async fn update_article(
State(state): State<AppState>,
Path(id): Path<u32>,
Json(update): Json<UpdateArticle>,
) -> Result<Json<Article>, StatusCode> {
let mut articles = state.articles.write().await;
let article = articles.get_mut(&id)
.ok_or(StatusCode::NOT_FOUND)?;
if let Some(title) = update.title { article.title = title; }
if let Some(content) = update.content { article.content = content; }
article.updated_at = Utc::now();
Ok(Json(article.clone()))
#[tokio::main]
async fn main() {
let state = AppState {
articles: Arc::new(RwLock::new(HashMap::new())),
};
let app = Router::new()
.route("/articles", post(create_article))
.route("/articles/:id", put(update_article))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await.unwrap();
println!("Server running at http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}The HashMap state used here is lost on restart. For production, use a persistent database (SQLx + PostgreSQL/SQLite) and consider connection pooling, migrations, and proper error recovery.