Building a Simple Web API with Rust and Axum

Last updated: April 13, 2025

1. Introduction: Why Axum?

Axum is a modern and ergonomic web framework for Rust, built by the same team that develops Tokio, the widely used asynchronous runtime. It emphasizes modularity, developer experience, and tight integration with the Tokio and Tower (middleware) ecosystems.

Compared to other frameworks like Actix Web or Rocket, Axum often appeals to developers looking for a slightly less opinionated structure than Rocket, potentially simpler state management than Actix's actor model (though Axum can use shared state), and excellent composability via Tower services.

This guide walks through building a basic RESTful API using Axum, covering setup, routing, handling requests, and returning JSON. Ensure you have Rust and Cargo installed (see Getting Started with Rust).

2. Project Setup

Create a new Rust binary project:

cargo new axum_api
cd axum_api

Add the necessary dependencies to your Cargo.toml:

[package]
name = "axum_api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.7"         # Check crates.io for latest version
tokio = { version = "1", features = ["full"] } # Need the tokio runtime
serde = { version = "1.0", features = ["derive"] } # For JSON serialization
serde_json = "1.0"

Alternatively, use cargo add:

cargo add axum tokio --features full
cargo add serde --features derive
cargo add serde_json

We include tokio with the "full" feature flag as Axum relies on it, and serde/serde_json for JSON handling.

3. Creating a Basic Server

Replace the contents of src/main.rs with a minimal Axum server:

use axum::{routing::get, Router};
use std::net::SocketAddr;

// Basic handler for the root path
async fn root_handler() -> &'static str {
    "Hello, Axum World!"
}

#[tokio::main] // Use the tokio runtime macro
async fn main() {
    // Build our application router
    let app = Router::new().route("/", get(root_handler)); // Map GET "/" to root_handler

    // Define the address to run the server on
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("🚀 Server listening on {}", addr);

    // Run the server
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Key elements:

  • use axum::{routing::get, Router};: Imports the necessary routing components.
  • async fn root_handler() -> &'static str: A simple asynchronous handler function returning a static string slice. Axum handlers can return various types that implement IntoResponse.
  • #[tokio::main]: Sets up the Tokio async runtime.
  • Router::new().route("/", get(root_handler)): Creates a router and defines that HTTP GET requests to the root path (/) should be handled by root_handler.
  • tokio::net::TcpListener::bind(addr).await: Binds a TCP listener to the specified address using Tokio's networking primitives.
  • axum::serve(listener, app).await: Starts the Axum server, serving the defined application router on the bound listener.

Run with cargo run. Accessing http://127.0.0.1:3000/ in your browser or via curl should show "Hello, Axum World!".

4. Routing and Handlers

Axum uses the Router to define endpoints. You chain .route() calls, specifying the path and the method handler (e.g., get(handler_fn), post(handler_fn)).

Let's add another route:

// Add this handler
async fn ping_handler() -> &'static str {
    "pong"
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root_handler))
        .route("/ping", get(ping_handler)); // Add the new route

    // ... (rest of the main function remains the same) ...
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("🚀 Server listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Re-run and access http://127.0.0.1:3000/ping to get "pong".

5. Using Extractors (Path Parameters)

Axum uses Extractors to get data from requests (path parameters, query parameters, JSON bodies, headers, etc.). Extractors are implemented as function arguments that implement the FromRequestParts or FromRequest traits.

To extract path parameters, use axum::extract::Path:

use axum::{routing::get, Router, extract::Path}; // Add Path
use std::net::SocketAddr;

// ... (root_handler, ping_handler) ...

// Handler to greet a user by name from the path
async fn greet_handler(Path(name): Path) -> String {
    // Path(name) extracts the parameter named 'name' from the URL
    format!("Hello, {}!", name)
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root_handler))
        .route("/ping", get(ping_handler))
        .route("/greet/:name", get(greet_handler)); // Define path param with :name

    // ... (rest of the main function remains the same) ...
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
     println!("🚀 Server listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Accessing http://127.0.0.1:3000/greet/AxumUser will now return "Hello, AxumUser!".

6. Returning JSON Responses

6.1 Serde Dependency

Ensure you have added serde and serde_json to your Cargo.toml as shown in the setup section. Define a struct and derive serde::Serialize.

// Add this near the top
use serde::Serialize;

#[derive(Serialize)] // Derive Serialize
struct StatusMessage {
    status: String,
    healthy: bool,
}

6.2 JSON Handler Example

To return JSON, use the axum::Json extractor/response type.

use axum::{routing::get, Router, extract::Path, Json}; // Add Json
use std::net::SocketAddr;
use serde::Serialize; // Add Serialize

// ... (StatusMessage struct, root_handler, ping_handler, greet_handler) ...

// Handler returning JSON
async fn status_handler() -> Json {
    let response = StatusMessage {
        status: "OK".to_string(),
        healthy: true,
    };
    Json(response) // Wrap the struct in Json() to serialize
}


#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root_handler))
        .route("/ping", get(ping_handler))
        .route("/greet/:name", get(greet_handler))
        .route("/status", get(status_handler)); // Add status route

    // ... (rest of the main function remains the same) ...
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    println!("🚀 Server listening on {}", addr);
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Accessing http://127.0.0.1:3000/status will return:

{
  "status": "OK",
  "healthy": true
}

7. Running and Testing

  • Run: Use cargo run.
  • Test: Use curl or an API client like Postman/Insomnia:
    curl http://127.0.0.1:3000/
    curl http://127.0.0.1:3000/ping
    curl http://127.0.0.1:3000/greet/RustDev
    curl http://127.0.0.1:3000/status

8. Conclusion and Next Steps

Axum provides an ergonomic and performant way to build web APIs in Rust, leveraging the power of the Tokio ecosystem. We've covered the basics of setting up a server, defining routes, using extractors for path parameters, and returning JSON data.

From here, you can explore:

  • Handling different HTTP methods (POST, PUT, DELETE).
  • Extracting query parameters (axum::extract::Query).
  • Extracting JSON request bodies (axum::Json as an extractor).
  • Managing application state (axum::extract::State).
  • Implementing middleware using Tower services.
  • Database integration (e.g., using SQLx).
  • More complex routing and application structuring.

9. Additional Resources

Related Articles

External Resources