Web Backends Basics
11 examples to get you started with Rust web backends - 8 basic and 3 intermediate.
Prerequisites
# Cargo.toml
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["trace"] }- Axum 0.8 on Tokio 1.x is the default choice for new async HTTP services.
- Run with
cargo runafter adding a#[tokio::main]entry point.
Basic Examples
1. Hello World Server
Minimal Axum app listening on port 3000.
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(|| async { "Hello, Axum" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Router::new()composes routes into a single service.axum::serveruns the app on a Tokio listener.- Handlers are async functions returning types that implement
IntoResponse.
Related: Axum Routing & Handlers - routes and methods
2. JSON Response
Return typed JSON from a handler.
use axum::{routing::get, Json, Router};
use serde::Serialize;
#[derive(Serialize)]
struct Health { status: &'static str }
async fn health() -> Json<Health> {
Json(Health { status: "ok" })
}
let app = Router::new().route("/health", get(health));Json<T>setsContent-Type: application/json.- Derive
Serializeon response types. - Axum serializes with serde automatically.
Related: Extractors & State -
Jsonextractor
3. Path Parameters
Capture typed values from the URL.
use axum::extract::Path;
async fn get_user(Path(id): Path<u64>) -> String {
format!("user {id}")
}Pathextractor parses and validates route segments.- Invalid types return 400 automatically.
- Parameter names must match route template
/:id.
4. Query Strings
Optional filters from the query string.
use axum::extract::Query;
use serde::Deserialize;
#[derive(Deserialize)]
struct ListParams { limit: Option<u32>, q: Option<String> }
async fn list(Query(params): Query<ListParams>) -> String {
format!("limit={:?} q={:?}", params.limit, params.q)
}Querydeserializes into anyDeserializetype.- Missing fields become
NoneforOption. - Use
serde(default)for sensible defaults.
5. POST JSON Body
Accept a typed request body.
use axum::{routing::post, Json};
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateItem { name: String }
async fn create(Json(body): Json<CreateItem>) -> String {
format!("created {}", body.name)
}Jsonextractor deserializes the request body.- Malformed JSON returns 422 with serde error details.
- Keep separate input and output types at API boundaries.
Related: Request Validation - field constraints
6. Shared Application State
Inject a database pool or config via State.
use axum::extract::State;
use std::sync::Arc;
#[derive(Clone)]
struct AppState { db_url: Arc<String> }
async fn info(State(state): State<AppState>) -> String {
state.db_url.clone()
}State<T>requiresT: Clone- wrap heavy data inArc.- Pass state with
.with_state(state)on the router. - One state type per application is typical.
Related: Extractors & State - state patterns
7. Router Nesting
Group routes under a prefix.
use axum::{routing::get, Router};
let users = Router::new()
.route("/", get(|| async { "list users" }))
.route("/:id", get(|| async { "get user" }));
let app = Router::new().nest("/users", users);nestmounts a sub-router at a path prefix.- Keeps large APIs organized by domain.
- Merge routers with
Router::mergefor sibling groups.
8. Method Routing
Different handlers per HTTP method on one path.
use axum::routing::{get, post};
let app = Router::new().route("/items", get(list_items).post(create_item));- Chain
.get(),.post(),.delete()on a route. - Unsupported methods return 405.
- Match REST conventions: GET read, POST create.
Related: Axum Routing & Handlers - method routing
Intermediate Examples
9. Tower Trace Layer
Add request logging middleware.
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.layer(TraceLayer::new_for_http());tower-httplayers wrap the entire router.- Trace layer logs method, path, status, and latency.
- Layer order matters - outer layers run first on requests.
Related: Middleware & Tower - layer composition
10. Typed Error Responses
Return consistent error JSON.
use axum::{http::StatusCode, response::IntoResponse, Json};
use serde::Serialize;
#[derive(Serialize)]
struct ApiError { code: &'static str, message: String }
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(StatusCode::BAD_REQUEST, Json(self)).into_response()
}
}- Custom error types implement
IntoResponse. - Pair status codes with JSON bodies.
- Centralize error mapping in one enum.
Related: Error Handling in Handlers - error enums
11. Graceful Shutdown
Stop accepting new connections on SIGTERM.
use tokio::signal;
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(async {
signal::ctrl_c().await.ok();
})
.await
.unwrap();with_graceful_shutdowndrains in-flight requests.- Listen for SIGTERM in containers, Ctrl+C locally.
- Pair with health checks for zero-downtime deploys.
Related: Testing Web Services - integration tests
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.