Building an API with Axum
Why Axum?
Section titled “Why Axum?”If you come from Node.js, you probably know Express or NestJS. Axum is Rust’s answer to that space — a high-level web framework that lets you write async fn route handlers, parse request data with type-safe extractors, and share state across routes.
But unlike Express, Axum is not batteries-included in the monolith sense. It is deliberately thin and delegates middleware to Tower, an industrial-strength, composable middleware library. The result is:
- Compile-time type safety across the entire request/response cycle.
- Zero-cost abstractions — no garbage collector, no hidden allocations.
- First-class async built on Tokio, the de facto Rust async runtime.
Axum vs Express vs NestJS
Section titled “Axum vs Express vs NestJS”| Feature | Express | NestJS | Axum |
|---|---|---|---|
| Language | TypeScript / JS | TypeScript | Rust |
| Routing | app.get(...) | @Get(...) decorator | Router::new().route(...) |
| Middleware | app.use(...) | @Middleware / guards | Tower layers |
| Request parsing | req.body, req.params | @Body(), @Param() | Extractors (Json, Path, Query) |
| State | app.locals | Dependency injection | State(...) extractor |
| Error handling | next(err) middleware | Exception filters | Result<T, E> + IntoResponse |
| Type safety | Partial (runtime) | Partial (runtime) | Full (compile-time) |
// Expressimport express from 'express';const app = express();
app.get('/', (req, res) => { res.json({ message: 'Hello, World!' });});
app.listen(3000, () => console.log('Listening on :3000'));use axum::{routing::get, Json, Router};use serde_json::{json, Value};
async fn hello() -> Json<Value> { Json(json!({ "message": "Hello, World!" }))}
#[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 :3000"); axum::serve(listener, app).await.unwrap();}Run locally — Axum binds a port and runs forever, so these server snippets cannot run in the browser sandbox. Use
cargo runafter following the project setup in the next lesson.
What you will build
Section titled “What you will build”Throughout this module you will build a small Users API with:
GET /users— list users (pagination withQuery)POST /users— create a user (Jsonbody)GET /users/{id}— get a single user (Path)DELETE /users/{id}— delete a user- Shared in-memory state (
Arc<Mutex<Vec<User>>>) - Structured JSON errors
- A Tower CORS layer
- SQLx database integration (conceptual)
- Handler tests with
tower::ServiceExt::oneshot
Each concept maps directly to something you already know from Express or NestJS.
Coming from Axum 0.7?
Section titled “Coming from Axum 0.7?”This module targets Axum 0.8. If you are porting a 0.7 codebase, or reading an older tutorial, these are the changes that will bite you:
| 0.7 | 0.8 |
|---|---|
.route("/users/:id", ...) | .route("/users/{id}", ...) — and /*rest becomes /{*rest} |
#[async_trait] on FromRequestParts | not needed; the trait uses native async fn |
Option<Path<T>> swallowed every error | it now rejects the request in most cases — implement OptionalFromRequestParts if you want the old behaviour |
Host extractor in axum | moved to the axum-extra crate |
Message::Text(String) | Message::Text(Utf8Bytes), and Message::Binary(Bytes) |
axum::body::{Empty, Full} re-exports | use axum::body::Body |
Handlers and services also have to be Sync now. The rest of the API — extractors, State, Tower layers — is unchanged, so a port is mostly a find-and-replace on route strings.