Axum Tutorial: Build a REST API in Rust Step by Step

Introduction

You’ve finished the Rust Essentials course — now let’s build something real. In this post we’ll create a REST API using Axum, the most popular async Rust web framework. Think of it as the Rust equivalent of our CrowCpp tutorial.

Axum runs on Tokio (async runtime) and Tower (middleware). It’s fast, ergonomic, and production-ready.

Project Setup

Create a new project:

cargo new axum-api
cd axum-api

Add to Cargo.toml:

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Step 1 — Hello World

use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", get(hello));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

async fn hello() -> &'static str {
    "Hello from Axum!"
}

Run with cargo run and open http://localhost:3000.

Step 2 — JSON Responses

Derive Serialize on a struct and wrap it in Axum’s Json extractor:

use axum::{routing::get, Json, Router};
use serde::Serialize;

#[derive(Serialize)]
struct User {
    id: u32,
    name: String,
}

async fn get_user() -> Json<User> {
    Json(User {
        id: 1,
        name: "Alice".to_string(),
    })
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/user", get(get_user));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

GET /user returns {"id":1,"name":"Alice"}.

Step 3 — Path Parameters

Extract values from the URL using Path:

use axum::{extract::Path, routing::get, Json, Router};
use serde::Serialize;

#[derive(Serialize)]
struct User {
    id: u32,
    name: String,
}

async fn get_user(Path(id): Path<u32>) -> Json<User> {
    Json(User { id, name: format!("User {}", id) })
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/user/:id", get(get_user));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

GET /user/42 returns {"id":42,"name":"User 42"}.

Step 4 — POST with JSON Body

Use Json as a function parameter to extract and deserialize the request body:

use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CreateUser {
    name: String,
}

#[derive(Serialize)]
struct User {
    id: u32,
    name: String,
}

async fn create_user(Json(payload): Json<CreateUser>) -> Json<User> {
    Json(User { id: 99, name: payload.name })
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/user", post(create_user));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Test with curl:

curl -X POST http://localhost:3000/user \
  -H "Content-Type: application/json" \
  -d '{"name": "Bob"}'

Step 5 — Status Codes

Return a tuple to control the HTTP status code:

use axum::{http::StatusCode, Json};
use serde::Serialize;

#[derive(Serialize)]
struct ErrorBody {
    message: String,
}

async fn not_found() -> (StatusCode, Json<ErrorBody>) {
    (
        StatusCode::NOT_FOUND,
        Json(ErrorBody { message: "Not found".to_string() }),
    )
}

Complete Example

use axum::{
    extract::Path,
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};

#[derive(Serialize, Clone)]
struct User {
    id: u32,
    name: String,
}

#[derive(Deserialize)]
struct CreateUser {
    name: String,
}

async fn list_users() -> Json<Vec<User>> {
    Json(vec![
        User { id: 1, name: "Alice".to_string() },
        User { id: 2, name: "Bob".to_string() },
    ])
}

async fn get_user(Path(id): Path<u32>) -> (StatusCode, Json<User>) {
    (StatusCode::OK, Json(User { id, name: format!("User {}", id) }))
}

async fn create_user(Json(payload): Json<CreateUser>) -> (StatusCode, Json<User>) {
    (StatusCode::CREATED, Json(User { id: 99, name: payload.name }))
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", get(list_users).post(create_user))
        .route("/users/:id", get(get_user));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running at http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

Exercises

  1. Add a DELETE /users/:id route that returns 204 No Content.
  2. Add a name query parameter to GET /users to filter by name. Use axum::extract::Query.
  3. Add a GET /health endpoint returning {"status":"ok"}.

What’s Next?

  • State — share database connections across handlers via State<T>
  • Middleware — logging, CORS, and auth via Tower layers
  • Database — pair with sqlx for SQLite or Postgres

Leave a Comment

Your email address will not be published. Required fields are marked *